@livx.cc/appwrap 0.28.0 → 0.28.2

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.
Files changed (40) hide show
  1. package/package.json +1 -1
  2. package/runtime/app/app.ts +55 -54
  3. package/runtime/app/shell/android-helpers.ts +6 -6
  4. package/runtime/app/shell/background-worker.android.ts +47 -0
  5. package/runtime/app/shell/background-worker.ios.ts +5 -0
  6. package/runtime/app/shell/background-worker.ts +7 -0
  7. package/runtime/app/shell/banner.ts +9 -4
  8. package/runtime/app/shell/bridge.ts +2 -0
  9. package/runtime/app/shell/custom-webview.android.ts +50 -23
  10. package/runtime/app/shell/custom-webview.ios.ts +90 -78
  11. package/runtime/app/shell/devmenu.ts +2 -2
  12. package/runtime/app/shell/env.ts +1 -3
  13. package/runtime/app/shell/events.ts +5 -4
  14. package/runtime/app/shell/handlers-android.ts +16 -12
  15. package/runtime/app/shell/handlers-background.ts +57 -78
  16. package/runtime/app/shell/handlers-billing.ts +90 -82
  17. package/runtime/app/shell/handlers-extended.ts +141 -110
  18. package/runtime/app/shell/handlers-fs.ts +53 -41
  19. package/runtime/app/shell/handlers-health.ts +27 -19
  20. package/runtime/app/shell/handlers-media.ts +8 -14
  21. package/runtime/app/shell/handlers-oauth.ts +19 -19
  22. package/runtime/app/shell/handlers-parity.ts +118 -89
  23. package/runtime/app/shell/handlers-push.ts +5 -3
  24. package/runtime/app/shell/handlers-reviews.ts +5 -6
  25. package/runtime/app/shell/handlers-scanner.ts +36 -38
  26. package/runtime/app/shell/handlers-speech.ts +43 -49
  27. package/runtime/app/shell/handlers-system.ts +1 -1
  28. package/runtime/app/shell/handlers.ts +4 -3
  29. package/runtime/app/shell/ios-frameworks.d.ts +9 -0
  30. package/runtime/app/shell/ios-ui-delegate.ts +112 -108
  31. package/runtime/app/shell/native-log.ts +1 -3
  32. package/runtime/app/shell/status-bar.ts +7 -4
  33. package/runtime/app/shell/toast.ts +1 -1
  34. package/runtime/references.d.ts +6 -0
  35. package/runtime/tsconfig.json +1 -0
  36. package/src/cli.ts +43 -18
  37. package/src/derive.ts +8 -0
  38. package/templates/ci/github/workflows/appwrap-pr.yml +3 -3
  39. package/templates/ci/github/workflows/appwrap-release-android.yml +2 -2
  40. package/templates/ci/github/workflows/appwrap-release-ios.yml +2 -2
@@ -18,23 +18,55 @@ import { appwrapNativeLog } from './native-log';
18
18
  const appendWebLog = appwrapNativeLog;
19
19
 
20
20
  /** Parse a #rrggbb config color into a UIColor (for the WebView background). */
21
- function uiColorFromHex(hex?: string): any {
21
+ function uiColorFromHex(hex?: string): UIColor | null {
22
22
  const m = /^#?([0-9a-fA-F]{6})$/.exec((hex ?? '').trim());
23
23
  if (!m) return null;
24
24
  const n = parseInt(m[1], 16);
25
25
  return UIColor.colorWithRedGreenBlueAlpha(((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255, 1);
26
26
  }
27
27
 
28
+ /** WKScriptMessageHandler for the `appwrap` channel — forwards JSON envelopes to the owning view. */
29
+ @NativeClass()
30
+ class AppwrapScriptHandler extends NSObject implements WKScriptMessageHandler {
31
+ static ObjCProtocols = [WKScriptMessageHandler];
32
+ owner!: WeakRef<CustomWebView>;
33
+ static new(): AppwrapScriptHandler {
34
+ return <AppwrapScriptHandler>super.new();
35
+ }
36
+ userContentControllerDidReceiveScriptMessage(_controller: WKUserContentController, message: WKScriptMessage): void {
37
+ const view = this.owner.get ? this.owner.get() : (this.owner as { deref(): CustomWebView }).deref();
38
+ const body = message.body;
39
+ // Envelopes always travel as JSON strings (protocol v1)
40
+ if (view?.onAppwrapMessage && typeof body === 'string') {
41
+ view.onAppwrapMessage(body);
42
+ }
43
+ }
44
+ }
45
+
46
+ /** WKScriptMessageHandler for the `appwrapLog` channel — debug console-forwarding to the file sink. */
47
+ @NativeClass()
48
+ class AppwrapLogHandler extends NSObject implements WKScriptMessageHandler {
49
+ static ObjCProtocols = [WKScriptMessageHandler];
50
+ static new(): AppwrapLogHandler {
51
+ return <AppwrapLogHandler>super.new();
52
+ }
53
+ userContentControllerDidReceiveScriptMessage(_c: WKUserContentController, message: WKScriptMessage): void {
54
+ // Write to the file sink — NS console.log / NSLog don't surface to devicectl/idevicesyslog
55
+ // on a device build, but a file in the app container is reliably pullable.
56
+ if (typeof message.body === 'string') appendWebLog(message.body);
57
+ }
58
+ }
59
+
28
60
  export class CustomWebView extends WebView {
29
61
  /** Set by the bridge before load; receives raw envelope JSON. */
30
62
  onAppwrapMessage: ((json: string) => void) | null = null;
31
- private _scriptHandler: any; // retained — WKUserContentController holds it weakly
32
- private _logHandler: any; // retained — debug console-forwarding handler
33
- private _schemeHandler: any;
63
+ private _scriptHandler!: AppwrapScriptHandler; // retained — WKUserContentController holds it weakly
64
+ private _logHandler!: AppwrapLogHandler; // retained — debug console-forwarding handler
65
+ private _schemeHandler!: WKURLSchemeHandler;
34
66
  // NOT `_uiDelegate`: the base NativeScript WebView uses that exact field name, and
35
67
  // TS `private` is compile-time only — sharing it let super.initNativeView() overwrite
36
68
  // our media-capture delegate with NS's, so getUserMedia re-prompted every call.
37
- private _appwrapUiDelegate: any; // retained — WKWebView holds uiDelegate weakly
69
+ private _appwrapUiDelegate!: WKUIDelegate; // retained — WKWebView holds uiDelegate weakly
38
70
 
39
71
  /** No-op on iOS: WKWebView suspends rAF/timers itself when the app leaves the foreground.
40
72
  * Kept for parity with the Android impl (wired to suspend/resume in main-page). */
@@ -67,39 +99,14 @@ export class CustomWebView extends WebView {
67
99
  }
68
100
 
69
101
  const owner = new WeakRef<CustomWebView>(this);
70
- const HandlerClass = (NSObject as any).extend(
71
- {
72
- userContentControllerDidReceiveScriptMessage(
73
- _controller: WKUserContentController,
74
- message: WKScriptMessage
75
- ): void {
76
- const view = owner.get ? owner.get() : (owner as any).deref();
77
- const body = message.body;
78
- // Envelopes always travel as JSON strings (protocol v1)
79
- if (view?.onAppwrapMessage && typeof body === 'string') {
80
- view.onAppwrapMessage(body);
81
- }
82
- },
83
- },
84
- { protocols: [WKScriptMessageHandler] }
85
- );
86
- this._scriptHandler = HandlerClass.new();
102
+ this._scriptHandler = AppwrapScriptHandler.new();
103
+ this._scriptHandler.owner = owner;
87
104
  config.userContentController.addScriptMessageHandlerName(this._scriptHandler, 'appwrap');
88
105
 
89
106
  // Debug console-forwarding: web console/errors → NSLog (via NS console.log) → device console,
90
107
  // so `appwrap logs ios` can stream them headlessly. Only wired in debug mode.
91
108
  if (SHELL_CONFIG.debug) {
92
- const LogClass = (NSObject as any).extend(
93
- {
94
- userContentControllerDidReceiveScriptMessage(_c: WKUserContentController, message: WKScriptMessage): void {
95
- // Write to the file sink — NS console.log / NSLog don't surface to devicectl/idevicesyslog
96
- // on a device build, but a file in the app container is reliably pullable.
97
- if (typeof message.body === 'string') appendWebLog(message.body);
98
- },
99
- },
100
- { protocols: [WKScriptMessageHandler] }
101
- );
102
- this._logHandler = LogClass.new();
109
+ this._logHandler = AppwrapLogHandler.new();
103
110
  config.userContentController.addScriptMessageHandlerName(this._logHandler, 'appwrapLog');
104
111
  appendWebLog('[native] log handler registered'); // boot marker: proves the file sink works
105
112
  }
@@ -124,13 +131,13 @@ export class CustomWebView extends WebView {
124
131
  // Edge-swipe to go back/forward in WebView history — the iOS native gesture
125
132
  // users expect. SPAs that own their own history still get it for free.
126
133
  webView.allowsBackForwardNavigationGestures = true;
127
- if ((webView.configuration as any).dataDetectorTypes !== undefined) {
128
- (webView.configuration as any).dataDetectorTypes = 0; // WKDataDetectorTypeNone
134
+ if (webView.configuration.dataDetectorTypes !== undefined) {
135
+ webView.configuration.dataDetectorTypes = 0; // WKDataDetectorTypeNone (WKDataDetectorTypes.None)
129
136
  }
130
137
 
131
138
  // Observability (debug mode only): make the WebView debuggable via Safari Web Inspector
132
139
  // (Develop → <device> → Para) — full console + network. iOS 16.4+ (guarded). Off in store builds.
133
- if (SHELL_CONFIG.debug && webView.respondsToSelector('setInspectable:')) (webView as any).inspectable = true;
140
+ if (SHELL_CONFIG.debug && webView.respondsToSelector('setInspectable:')) webView.inspectable = true;
134
141
 
135
142
  this._appwrapUiDelegate = createUiDelegate();
136
143
  webView.UIDelegate = this._appwrapUiDelegate;
@@ -147,9 +154,9 @@ export class CustomWebView extends WebView {
147
154
  const wk = this.nativeViewProtected as WKWebView;
148
155
  if (wk && this._appwrapUiDelegate) wk.UIDelegate = this._appwrapUiDelegate;
149
156
  // Lightweight self-check surfaced via debug.webviewInfo (support diagnostics).
150
- (global as any).__appwrapWebviewDiag =
157
+ (global as { __appwrapWebviewDiag?: string }).__appwrapWebviewDiag =
151
158
  `delegateIsMine=${!!wk && wk.UIDelegate === this._appwrapUiDelegate} ` +
152
- `respondsToMedia=${!!wk && !!wk.UIDelegate && !!(wk.UIDelegate as any).respondsToSelector?.('webView:requestMediaCapturePermissionForOrigin:initiatedByFrame:type:decisionHandler:')}`;
159
+ `respondsToMedia=${!!wk && !!wk.UIDelegate && !!wk.UIDelegate.respondsToSelector?.('webView:requestMediaCapturePermissionForOrigin:initiatedByFrame:type:decisionHandler:')}`;
153
160
  }
154
161
  }
155
162
 
@@ -163,41 +170,42 @@ export class CustomWebView extends WebView {
163
170
  * request body, so POSTs proxy fine.
164
171
  * - else → 404 (or SPA fallback when there's no backend).
165
172
  */
166
- function createAppSchemeHandler(): any {
173
+ function createAppSchemeHandler(): WKURLSchemeHandler {
167
174
  const wwwPath = nsPath.join(knownFolders.currentApp().path, 'www');
168
175
 
169
- const serveFile = (task: any, url: any, filePath: string, ext: string): void => {
176
+ const serveFile = (task: WKURLSchemeTask, url: NSURL, filePath: string, ext: string): void => {
170
177
  const data = NSData.dataWithContentsOfFile(filePath);
171
178
  const mime = mimeFor(ext, filePath);
172
179
  const response = NSHTTPURLResponse.alloc().initWithURLStatusCodeHTTPVersionHeaderFields(
173
180
  url, 200, 'HTTP/1.1',
181
+ // NS auto-marshals this JS object into NSDictionary<string,string>
174
182
  {
175
183
  'Content-Type': `${mime}; charset=utf-8`,
176
184
  'Content-Length': String(data?.length ?? 0),
177
185
  'Access-Control-Allow-Origin': '*',
178
186
  'Cache-Control': 'no-cache',
179
- } as any
187
+ } as unknown as NSDictionary<string, string>
180
188
  );
181
189
  task.didReceiveResponse(response);
182
190
  if (data) task.didReceiveData(data);
183
191
  task.didFinish();
184
192
  };
185
193
 
186
- const proxyToBackend = (task: any, url: any, rel: string): void => {
194
+ const proxyToBackend = (task: WKURLSchemeTask, url: NSURL, rel: string): void => {
187
195
  const backend = SHELL_CONFIG.backendOrigin.replace(/\/+$/, '');
188
196
  const query = url.query ? `?${url.query}` : '';
189
197
  const req = NSMutableURLRequest.requestWithURL(NSURL.URLWithString(`${backend}/${rel}${query}`));
190
198
  req.HTTPMethod = String(task.request.HTTPMethod ?? 'GET');
191
199
  const hdrs = task.request.allHTTPHeaderFields;
192
200
  if (hdrs) req.allHTTPHeaderFields = hdrs;
193
- req.setValueForHTTPHeaderField(null as any, 'Host'); // let URLSession set Host from the target URL
201
+ req.setValueForHTTPHeaderField(null as unknown as string, 'Host'); // let URLSession set Host from the target URL
194
202
  // WKURLSchemeHandler delivers a POST/PUT body via HTTPBodyStream (not HTTPBody) — read it.
195
- let body = task.request.HTTPBody;
203
+ let body: NSData = task.request.HTTPBody;
196
204
  if (!body && task.request.HTTPBodyStream) {
197
205
  const stream = task.request.HTTPBodyStream;
198
206
  const acc = NSMutableData.alloc().init();
199
207
  const cap = 16384;
200
- const buf = (global as any).interop.alloc(cap);
208
+ const buf = interop.alloc(cap);
201
209
  stream.open();
202
210
  while (stream.hasBytesAvailable) {
203
211
  const n = stream.readMaxLength(buf, cap);
@@ -211,11 +219,11 @@ function createAppSchemeHandler(): any {
211
219
 
212
220
  const dataTask = NSURLSession.sharedSession.dataTaskWithRequestCompletionHandler(
213
221
  req,
214
- (data: any, resp: any, error: any) => {
222
+ (data: NSData, resp: NSURLResponse, error: NSError) => {
215
223
  try {
216
224
  if (error || !resp) {
217
225
  const r = NSHTTPURLResponse.alloc().initWithURLStatusCodeHTTPVersionHeaderFields(
218
- url, 502, 'HTTP/1.1', { 'Content-Length': '0' } as any
226
+ url, 502, 'HTTP/1.1', { 'Content-Length': '0' } as unknown as NSDictionary<string, string>
219
227
  );
220
228
  task.didReceiveResponse(r);
221
229
  task.didFinish();
@@ -223,10 +231,12 @@ function createAppSchemeHandler(): any {
223
231
  }
224
232
  // Re-emit under the app:// URL (same-origin). Strip Content-Encoding (URLSession already
225
233
  // decoded the body) and recompute length; keep the upstream Content-Type + status.
226
- const ct = resp.valueForHTTPHeaderField?.('Content-Type') ?? 'application/octet-stream';
234
+ // resp is NSHTTPURLResponse at runtime (HTTP request) — cast for the HTTP-only members.
235
+ const httpResp = resp as NSHTTPURLResponse;
236
+ const ct = httpResp.valueForHTTPHeaderField?.('Content-Type') ?? 'application/octet-stream';
227
237
  const out = NSHTTPURLResponse.alloc().initWithURLStatusCodeHTTPVersionHeaderFields(
228
- url, resp.statusCode ?? 200, 'HTTP/1.1',
229
- { 'Content-Type': ct, 'Content-Length': String(data?.length ?? 0), 'Cache-Control': 'no-cache' } as any
238
+ url, httpResp.statusCode ?? 200, 'HTTP/1.1',
239
+ { 'Content-Type': ct, 'Content-Length': String(data?.length ?? 0), 'Cache-Control': 'no-cache' } as unknown as NSDictionary<string, string>
230
240
  );
231
241
  task.didReceiveResponse(out);
232
242
  if (data) task.didReceiveData(data);
@@ -239,36 +249,38 @@ function createAppSchemeHandler(): any {
239
249
  dataTask.resume();
240
250
  };
241
251
 
242
- const HandlerClass = (NSObject as any).extend(
243
- {
244
- webViewStartURLSchemeTask(_webView: WKWebView, task: any): void {
245
- const url = task.request.URL;
246
- let rel = decodeURIComponent(String(url.path ?? '/')).replace(/^\/+/, '');
247
- if (!rel) rel = SHELL_CONFIG.entry;
248
- const filePath = nsPath.join(wwwPath, rel);
249
- const ext = rel.includes('.') ? rel.split('.').pop()!.toLowerCase() : '';
252
+ @NativeClass()
253
+ class AppSchemeHandler extends NSObject implements WKURLSchemeHandler {
254
+ static ObjCProtocols = [WKURLSchemeHandler];
255
+ static new(): AppSchemeHandler {
256
+ return <AppSchemeHandler>super.new();
257
+ }
258
+ webViewStartURLSchemeTask(_webView: WKWebView, task: WKURLSchemeTask): void {
259
+ const url = task.request.URL;
260
+ let rel = decodeURIComponent(String(url.path ?? '/')).replace(/^\/+/, '');
261
+ if (!rel) rel = SHELL_CONFIG.entry;
262
+ const filePath = nsPath.join(wwwPath, rel);
263
+ const ext = rel.includes('.') ? rel.split('.').pop()!.toLowerCase() : '';
250
264
 
251
- if (File.exists(filePath)) { serveFile(task, url, filePath, ext); return; }
265
+ if (File.exists(filePath)) { serveFile(task, url, filePath, ext); return; }
252
266
 
253
- const method = String(task.request.HTTPMethod ?? 'GET').toUpperCase();
254
- const accept = task.request.valueForHTTPHeaderField?.('Accept') ?? '';
255
- const isNavigation = method === 'GET' && accept.indexOf('text/html') >= 0;
267
+ const method = String(task.request.HTTPMethod ?? 'GET').toUpperCase();
268
+ const accept = task.request.valueForHTTPHeaderField?.('Accept') ?? '';
269
+ const isNavigation = method === 'GET' && accept.indexOf('text/html') >= 0;
256
270
 
257
- if (isNavigation && !ext) { serveFile(task, url, nsPath.join(wwwPath, SHELL_CONFIG.entry), 'html'); return; }
258
- if (SHELL_CONFIG.backendOrigin) { proxyToBackend(task, url, rel); return; }
259
- if (!ext) { serveFile(task, url, nsPath.join(wwwPath, SHELL_CONFIG.entry), 'html'); return; }
271
+ if (isNavigation && !ext) { serveFile(task, url, nsPath.join(wwwPath, SHELL_CONFIG.entry), 'html'); return; }
272
+ if (SHELL_CONFIG.backendOrigin) { proxyToBackend(task, url, rel); return; }
273
+ if (!ext) { serveFile(task, url, nsPath.join(wwwPath, SHELL_CONFIG.entry), 'html'); return; }
260
274
 
261
- const resp = NSHTTPURLResponse.alloc().initWithURLStatusCodeHTTPVersionHeaderFields(
262
- url, 404, 'HTTP/1.1', { 'Content-Length': '0' } as any
263
- );
264
- task.didReceiveResponse(resp);
265
- task.didFinish();
266
- },
267
- webViewStopURLSchemeTask(_webView: WKWebView, _task: any): void {
268
- // single-shot responses; in-flight proxy completions are guarded by try/catch.
269
- },
270
- },
271
- { protocols: [WKURLSchemeHandler] }
272
- );
273
- return HandlerClass.new();
275
+ const resp = NSHTTPURLResponse.alloc().initWithURLStatusCodeHTTPVersionHeaderFields(
276
+ url, 404, 'HTTP/1.1', { 'Content-Length': '0' } as unknown as NSDictionary<string, string>
277
+ );
278
+ task.didReceiveResponse(resp);
279
+ task.didFinish();
280
+ }
281
+ webViewStopURLSchemeTask(_webView: WKWebView, _task: WKURLSchemeTask): void {
282
+ // single-shot responses; in-flight proxy completions are guarded by try/catch.
283
+ }
284
+ }
285
+ return AppSchemeHandler.new();
274
286
  }
@@ -19,7 +19,7 @@ export function startDevMenu(): void {
19
19
 
20
20
  // ── shake detection (total acceleration magnitude in g incl. gravity, debounced) ──
21
21
  const SHAKE_G = 1.8; // spike vs. ~1g rest
22
- let shakeMm: any = null; // hold a ref so CoreMotion isn't GC'd
22
+ let shakeMm: CMMotionManager | null = null; // hold a ref so CoreMotion isn't GC'd
23
23
  let firstSpikeAt = 0;
24
24
  let spikes = 0;
25
25
  let lastMenuAt = 0;
@@ -68,7 +68,7 @@ function startAndroidShake(): void {
68
68
  const G = 9.80665;
69
69
  const listener = new android.hardware.SensorEventListener({
70
70
  onAccuracyChanged() {},
71
- onSensorChanged(e: any) {
71
+ onSensorChanged(e: android.hardware.SensorEvent) {
72
72
  const v = e.values;
73
73
  onAccelMagnitude(Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]) / G);
74
74
  },
@@ -1,7 +1,5 @@
1
1
  import { Application, isAndroid } from '@nativescript/core';
2
2
 
3
- declare const android: any, NSProcessInfo: any, UIAccessibilityIsReduceMotionEnabled: any;
4
-
5
3
  /**
6
4
  * Runtime environment hints the page can't reliably detect itself, surfaced natively so a PWA can
7
5
  * scale its rendering down inside the shell. Injected at document-start as `window.__APPWRAP__` (see
@@ -36,7 +34,7 @@ export function envGlobalsJs(): string {
36
34
  function androidIsEmulator(): boolean {
37
35
  try {
38
36
  const B = android.os.Build;
39
- const has = (v: any, s: string) => String(v || '').toLowerCase().includes(s);
37
+ const has = (v: string, s: string) => String(v || '').toLowerCase().includes(s);
40
38
  return (
41
39
  has(B.FINGERPRINT, 'generic') || has(B.FINGERPRINT, 'emulator') || has(B.FINGERPRINT, 'unknown') ||
42
40
  has(B.MODEL, 'google_sdk') || has(B.MODEL, 'emulator') || has(B.MODEL, 'android sdk built for') ||
@@ -1,4 +1,5 @@
1
1
  import { Application, Connectivity, isAndroid } from '@nativescript/core';
2
+ import type { AndroidActivityNewIntentEventData, OrientationChangedEventData } from '@nativescript/core';
2
3
  import { bridge } from './bridge';
3
4
  import { connectivityStatus } from './handlers-extended';
4
5
 
@@ -74,7 +75,7 @@ export function onPwaHandshake(): void {
74
75
  export function startEventForwarding(): void {
75
76
  Application.on(Application.suspendEvent, () => bridge.emit('app.pause'));
76
77
  Application.on(Application.resumeEvent, () => bridge.emit('app.resume'));
77
- Application.on(Application.orientationChangedEvent, (args: any) =>
78
+ Application.on(Application.orientationChangedEvent, (args: OrientationChangedEventData) =>
78
79
  bridge.emit('screen.orientation.change', args?.newValue === 'landscape' ? 'landscape' : 'portrait')
79
80
  );
80
81
 
@@ -86,7 +87,7 @@ export function startEventForwarding(): void {
86
87
  /** Intents: launch intent (cold start) + onNewIntent (warm, singleTask). Carries both VIEW deep links
87
88
  * and FCM notification-tap extras. */
88
89
  function wireAndroidDeepLinks(): void {
89
- const emitFromIntent = (intent: any) => {
90
+ const emitFromIntent = (intent: android.content.Intent | null | undefined) => {
90
91
  try {
91
92
  const data = intent?.getData?.();
92
93
  if (data) onDeepLink(String(data.toString()));
@@ -102,7 +103,7 @@ function wireAndroidDeepLinks(): void {
102
103
  }
103
104
  };
104
105
 
105
- Application.android.on(Application.android.activityNewIntentEvent as any, (args: any) =>
106
+ Application.android.on(Application.android.activityNewIntentEvent, (args: AndroidActivityNewIntentEventData) =>
106
107
  emitFromIntent(args.intent)
107
108
  );
108
109
  emitFromIntent(Application.android.startActivity?.getIntent?.());
@@ -111,7 +112,7 @@ function wireAndroidDeepLinks(): void {
111
112
  /** A tapped FCM notification re-launches the activity with the message's data payload as string
112
113
  * extras, alongside FCM's own `google.*`/`gcm.*`/`from`/`collapse_key` bookkeeping keys. Detect via
113
114
  * those markers; return the app's data keys only. Null when this isn't an FCM-originated intent. */
114
- function readFcmTapExtras(intent: any): { data: Record<string, string> } | null {
115
+ function readFcmTapExtras(intent: android.content.Intent | null | undefined): { data: Record<string, string> } | null {
115
116
  try {
116
117
  const extras = intent?.getExtras?.();
117
118
  if (!extras || (!extras.containsKey('google.message_id') && !extras.containsKey('from'))) return null;
@@ -2,7 +2,8 @@ import { Application, Utils } from '@nativescript/core';
2
2
  import { bridge } from './bridge';
3
3
  import { requestPermissions, startActivityForResult, uriToDataUrl, bitmapToDataUrl } from './android-helpers';
4
4
 
5
- declare const android: any, java: any, androidx: any;
5
+ // no NS types: android-32 typings omit ContactsContract/MediaStore column + ACTION_PICK_IMAGES constants this file reads
6
+ declare const android: any, androidx: any;
6
7
 
7
8
  const err = (code: string, message: string) => Object.assign(new Error(message), { code });
8
9
 
@@ -125,18 +126,21 @@ export function registerAndroidHandlers(): void {
125
126
  // ── geolocation (LocationManager) ──────────────────────────────────
126
127
  const GEO_PERMS = ['android.permission.ACCESS_FINE_LOCATION', 'android.permission.ACCESS_COARSE_LOCATION'];
127
128
  const locationManager = () => context().getSystemService(android.content.Context.LOCATION_SERVICE);
128
- const toPosition = (loc: any) => ({
129
+ const toPosition = (loc: android.location.Location) => ({
129
130
  lat: loc.getLatitude(), lng: loc.getLongitude(), accuracy: loc.getAccuracy(),
130
131
  });
131
- const makeListener = (onLocation: (loc: any) => void) =>
132
+ const makeListener = (onLocation: (loc: android.location.Location) => void) =>
132
133
  new android.location.LocationListener({
133
- // Both overloads dispatch here — newer Android may deliver a List<Location>
134
- onLocationChanged(loc: any) {
134
+ // Both overloads dispatch here — newer Android may deliver a List<Location>.
135
+ onLocationChanged(arg: android.location.Location | java.util.List<android.location.Location>) {
136
+ // interop: runtime duck-typing across the two Java overloads; TS can't narrow Java types here
137
+ let loc: any = arg;
135
138
  if (loc && typeof loc.getLatitude !== 'function' && typeof loc.size === 'function') {
136
139
  loc = loc.size() > 0 ? loc.get(loc.size() - 1) : null;
137
140
  }
138
141
  if (loc) onLocation(loc);
139
142
  },
143
+ onFlushComplete() {},
140
144
  onStatusChanged() {}, onProviderEnabled() {}, onProviderDisabled() {},
141
145
  });
142
146
 
@@ -148,7 +152,7 @@ export function registerAndroidHandlers(): void {
148
152
  ?? lm.getLastKnownLocation(android.location.LocationManager.NETWORK_PROVIDER);
149
153
  if (last) return resolve(toPosition(last));
150
154
 
151
- let listener: any = null;
155
+ let listener: android.location.LocationListener | null = null;
152
156
  const timer = setTimeout(() => {
153
157
  if (listener) lm.removeUpdates(listener);
154
158
  reject(err('TIMEOUT', 'location timeout'));
@@ -165,7 +169,7 @@ export function registerAndroidHandlers(): void {
165
169
  });
166
170
  });
167
171
 
168
- let geoWatchListener: any = null;
172
+ let geoWatchListener: android.location.LocationListener | null = null;
169
173
  bridge.register('geo.watch.start', async () => {
170
174
  if (geoWatchListener) return; // already streaming
171
175
  if (!(await requestPermissions(GEO_PERMS))) throw err('DENIED', 'location permission denied');
@@ -249,7 +253,7 @@ export function registerAndroidHandlers(): void {
249
253
 
250
254
  // ── motion (SensorManager: accelerometer + gyroscope) ──────────────
251
255
  const sensorManager = () => context().getSystemService(android.content.Context.SENSOR_SERVICE);
252
- let motionListener: any = null;
256
+ let motionListener: android.hardware.SensorEventListener | null = null;
253
257
  const lastMotion = { ax: 0, ay: 0, az: 0, rx: 0, ry: 0, rz: 0 };
254
258
 
255
259
  bridge.register('motion.start', () => {
@@ -261,7 +265,7 @@ export function registerAndroidHandlers(): void {
261
265
  let last = 0;
262
266
  motionListener = new android.hardware.SensorEventListener({
263
267
  onAccuracyChanged() {},
264
- onSensorChanged(event: any) {
268
+ onSensorChanged(event: android.hardware.SensorEvent) {
265
269
  const v = event.values;
266
270
  if (event.sensor.getType() === android.hardware.Sensor.TYPE_ACCELEROMETER) {
267
271
  // Android accelerometer already reports m/s² incl. gravity — kit contract matches.
@@ -296,7 +300,7 @@ export function registerAndroidHandlers(): void {
296
300
  if (resultCode !== android.app.Activity.RESULT_OK || !result?.getData()) return { picked: false };
297
301
 
298
302
  const cr = context().getContentResolver();
299
- const column = (uri: any, col: string, sel: string | null, args: string[] | null): string[] => {
303
+ const column = (uri: android.net.Uri, col: string, sel: string | null, args: string[] | null): string[] => {
300
304
  const out: string[] = [];
301
305
  const cursor = cr.query(uri, [col], sel, args, null);
302
306
  if (cursor) {
@@ -327,7 +331,7 @@ export function registerAndroidHandlers(): void {
327
331
  }
328
332
  const CC = android.provider.ContactsContract;
329
333
  const cr = context().getContentResolver();
330
- const column = (uri: any, col: string, sel: string | null, args: string[] | null): string[] => {
334
+ const column = (uri: android.net.Uri, col: string, sel: string | null, args: string[] | null): string[] => {
331
335
  const out: string[] = [];
332
336
  const cursor = cr.query(uri, [col], sel, args, null);
333
337
  if (cursor) {
@@ -415,7 +419,7 @@ export function registerAndroidHandlers(): void {
415
419
  dir.mkdirs();
416
420
  const uris = new java.util.ArrayList();
417
421
  const mimes = new java.util.HashSet();
418
- (files ?? []).forEach((f: any, i: number) => {
422
+ (files ?? []).forEach((f: { name: string; mimeType: string; base64: string }, i: number) => {
419
423
  const bytes = android.util.Base64.decode(f.base64, android.util.Base64.DEFAULT);
420
424
  const out = new java.io.File(dir, i + '-' + (f.name || 'file')); // index-prefix: avoid same-name collisions
421
425
  const fos = new java.io.FileOutputStream(out);
@@ -3,16 +3,15 @@ import { bridge } from './bridge';
3
3
  import { SHELL_CONFIG } from './config';
4
4
  import { setPendingBackgroundTaskId } from './background-context';
5
5
  import { CustomWebView } from './custom-webview';
6
+ // Android-only WorkManager Worker (@JavaProxy + `extends androidx.work.Worker`). Kept in a `.android.ts`
7
+ // file so it's NEVER evaluated on iOS — a top-level Android native class in a shared module dereferences
8
+ // `@JavaProxy`/`androidx` at module load, which are undefined on iOS → the whole ES module graph fails to
9
+ // instantiate → hard launch crash. iOS resolves the `.ios.ts` stub; registerAndroid() is the only user.
10
+ import { AppwrapBackgroundWorker } from './background-worker';
6
11
 
7
- // ── BackgroundTasks (iOS) — NS auto-links the framework via metadata when these are referenced.
8
- // Not in the default @nativescript/types-ios set, so declared `any` (repo idiom; see handlers-speech).
9
- declare const BGTaskScheduler: any;
10
- declare const BGAppRefreshTaskRequest: any;
11
- declare const BGProcessingTaskRequest: any;
12
- declare const NSDate: any;
13
- declare const android: any;
14
- declare const androidx: any;
15
- declare const java: any;
12
+ // BackgroundTasks (iOS), NSDate, and the `android`/`java` namespaces resolve from the full SDK
13
+ // (@nativescript/types-ios + types-android) — no declares needed.
14
+ declare const androidx: any; // no NS types: androidx (not in types-android)
16
15
 
17
16
  const err = (code: string, message: string) => Object.assign(new Error(message), { code });
18
17
 
@@ -64,7 +63,7 @@ const pendingRuns = new Map<string, PendingRun>();
64
63
  /** Build an offscreen WebView, attach the bridge, and load the app so its handshake reports `id`. The
65
64
  * returned promise resolves when the JS handler calls `backgroundTask.finish` (or `abort()` fires).
66
65
  * REUSES `CustomWebView` (scheme handler + bridge injection) — no duplicated transport. */
67
- function runHeadless(id: string): Promise<boolean> {
66
+ export function runHeadless(id: string): Promise<boolean> {
68
67
  return new Promise<boolean>((resolve) => {
69
68
  setPendingBackgroundTaskId(id); // the next handshake reports this wake id
70
69
  const webView = new CustomWebView();
@@ -115,7 +114,7 @@ function loadAppInto(webView: CustomWebView, id: string, attempt = 0): void {
115
114
 
116
115
  /** Resolve an in-flight headless run (called by `backgroundTask.finish`, the safety abort, or a load
117
116
  * failure). Tears the offscreen WebView's bridge attachment down. Idempotent. */
118
- function finishRun(id: string, success: boolean): void {
117
+ export function finishRun(id: string, success: boolean): void {
119
118
  const run = pendingRuns.get(id);
120
119
  if (!run) return;
121
120
  pendingRuns.delete(id);
@@ -154,18 +153,22 @@ function registerIos(): void {
154
153
  * else a lighter app-refresh request. `minIntervalMs` → earliestBeginDate floor. */
155
154
  function submitIosRequest(id: string, p: any): void {
156
155
  const needsProcessing = !!p?.requiresNetwork || !!p?.requiresCharging;
157
- const req = needsProcessing
158
- ? BGProcessingTaskRequest.alloc().initWithIdentifier(id)
159
- : BGAppRefreshTaskRequest.alloc().initWithIdentifier(id);
156
+ let req: BGProcessingTaskRequest | BGAppRefreshTaskRequest;
160
157
  if (needsProcessing) {
161
- req.requiresNetworkConnectivity = !!p?.requiresNetwork;
162
- req.requiresExternalPower = !!p?.requiresCharging;
158
+ const proc = BGProcessingTaskRequest.alloc().initWithIdentifier(id);
159
+ proc.requiresNetworkConnectivity = !!p?.requiresNetwork;
160
+ proc.requiresExternalPower = !!p?.requiresCharging;
161
+ req = proc;
162
+ } else {
163
+ req = BGAppRefreshTaskRequest.alloc().initWithIdentifier(id);
163
164
  }
164
165
  if (p?.minIntervalMs) {
165
- req.earliestBeginDate = NSDate.dateWithTimeIntervalSinceNow(Number(p.minIntervalMs) / 1000);
166
+ // earliestBeginDate is a JS Date (NS marshals it to NSDate) — `now + interval`.
167
+ req.earliestBeginDate = new Date(Date.now() + Number(p.minIntervalMs));
166
168
  }
167
169
  // submitTaskRequestError throws via the out-error; NS surfaces it as a thrown JS error.
168
- BGTaskScheduler.sharedScheduler.submitTaskRequestError(req, null);
170
+ // (out-error param omitted — NS marshals the ObjC error into a thrown JS exception.)
171
+ BGTaskScheduler.sharedScheduler.submitTaskRequestError(req);
169
172
  }
170
173
 
171
174
  /**
@@ -176,27 +179,40 @@ function submitIosRequest(id: string, p: any): void {
176
179
  */
177
180
  export function registerBackgroundTaskLaunchHandlers(): void {
178
181
  if (!isIOS) return;
182
+ // This runs INSIDE applicationDidFinishLaunchingWithOptions, so ANY throw here crashes app launch.
183
+ // Guard hard: a background-task convenience must NEVER take down boot. If BGTaskScheduler is
184
+ // unavailable (framework not resolved) or a registration throws (Apple's strict rules), log and
185
+ // continue WITHOUT background wakes rather than crash. Each id is isolated so one bad id can't stop
186
+ // the rest. (`typeof` is a safe undefined-check — no ReferenceError on an absent native global.)
187
+ if (typeof BGTaskScheduler === 'undefined' || !BGTaskScheduler) {
188
+ console.log('[backgroundTask] BGTaskScheduler unavailable — skipping launch-handler registration');
189
+ return;
190
+ }
179
191
  for (const id of permittedIosIds()) {
180
- // The launch handler runs on the queue we pass (null = a default background queue). It receives the
181
- // BGTask; we drive the JS handler, then complete + reschedule.
182
- BGTaskScheduler.sharedScheduler.registerForTaskWithIdentifierUsingQueueLaunchHandler(
183
- id,
184
- null,
185
- (task: any) => {
186
- // The OS budget is nearly spent → abort the run (the kit also self-aborts at ~25s). Reporting
187
- // failure lets the OS learn the task overran.
188
- task.expirationHandler = () => finishRun(id, false);
189
- runHeadless(id)
190
- .then((success) => {
191
- try { submitIosRequest(id, {}); } catch { /* a failed resubmit shouldn't crash the wake */ }
192
- task.setTaskCompletedWithSuccess(success);
193
- })
194
- .catch(() => {
195
- try { submitIosRequest(id, {}); } catch { /* noop */ }
196
- task.setTaskCompletedWithSuccess(false);
197
- });
198
- }
199
- );
192
+ try {
193
+ // The launch handler runs on the queue we pass (null = a default background queue). It receives the
194
+ // BGTask; we drive the JS handler, then complete + reschedule.
195
+ BGTaskScheduler.sharedScheduler.registerForTaskWithIdentifierUsingQueueLaunchHandler(
196
+ id,
197
+ null as any, // interop: pass nil queue → OS default background queue (param is non-optional)
198
+ (task: BGTask) => {
199
+ // The OS budget is nearly spent → abort the run (the kit also self-aborts at ~25s). Reporting
200
+ // failure lets the OS learn the task overran.
201
+ task.expirationHandler = () => finishRun(id, false);
202
+ runHeadless(id)
203
+ .then((success) => {
204
+ try { submitIosRequest(id, {}); } catch { /* a failed resubmit shouldn't crash the wake */ }
205
+ task.setTaskCompletedWithSuccess(success);
206
+ })
207
+ .catch(() => {
208
+ try { submitIosRequest(id, {}); } catch { /* noop */ }
209
+ task.setTaskCompletedWithSuccess(false);
210
+ });
211
+ }
212
+ );
213
+ } catch (e: any) {
214
+ console.log(`[backgroundTask] launch-handler registration failed for '${id}': ${e?.message ?? e}`);
215
+ }
200
216
  }
201
217
  }
202
218
 
@@ -211,7 +227,7 @@ function registerAndroid(): void {
211
227
  // WorkManager periodic floor is 15 min; clamp a smaller hint up so enqueue doesn't reject it.
212
228
  const ms = Math.max(15 * 60_000, Number(p?.minIntervalMs ?? 15 * 60_000));
213
229
  const builder = new androidx.work.PeriodicWorkRequest.Builder(
214
- AppwrapBackgroundWorker.class,
230
+ (AppwrapBackgroundWorker as any).class,
215
231
  ms, java.util.concurrent.TimeUnit.MILLISECONDS
216
232
  );
217
233
  // The id rides as input data → the Worker reads it to drive the matching JS handler.
@@ -238,42 +254,5 @@ function registerAndroid(): void {
238
254
  });
239
255
  }
240
256
 
241
- /**
242
- * WorkManager headless Worker — created by WorkManager on a background launch. `doWork()` posts to the
243
- * MAIN looper (the WebView must be built + driven on the UI thread), runs the headless WebView loop for
244
- * the input task id, and blocks the worker thread on a `CountDownLatch` until `backgroundTask.finish`
245
- * (or a timeout). Returns success/failure; WorkManager handles periodic rescheduling.
246
- *
247
- * ⚠ DEVICE-UNVERIFIED — compiles only (see the file header).
248
- */
249
- @NativeClass()
250
- @JavaProxy('cc.livx.appwrap.AppwrapBackgroundWorker')
251
- export class AppwrapBackgroundWorker extends androidx.work.Worker {
252
- constructor(context: any, params: any) {
253
- super(context, params);
254
- }
255
-
256
- doWork(): any {
257
- const id = this.getInputData().getString('appwrap.taskId') ?? '';
258
- if (!id) return androidx.work.ListenableWorker.Result.failure();
259
-
260
- const latch = new java.util.concurrent.CountDownLatch(1);
261
- const result = { success: false };
262
- Utils.dispatchToMainThread(() => {
263
- runHeadless(id)
264
- .then((ok: boolean) => { result.success = ok; latch.countDown(); })
265
- .catch(() => { latch.countDown(); });
266
- });
267
- // Block the worker thread (bounded — below the WorkManager 10-min ceiling) until the JS handler
268
- // finishes. A timeout returns failure so WorkManager retries on its schedule.
269
- const completed = latch.await(9, java.util.concurrent.TimeUnit.MINUTES);
270
- if (!completed) finishRun(id, false);
271
- return completed && result.success
272
- ? androidx.work.ListenableWorker.Result.success()
273
- : androidx.work.ListenableWorker.Result.failure();
274
- }
275
- }
276
-
277
- // Reference the Worker class so the bundler/NS metadata retains the JavaProxy (mirrors how the FCM
278
- // service is kept alive via its import side-effect). Without a reference the class can be tree-shaken.
279
- void AppwrapBackgroundWorker;
257
+ // The WorkManager headless Worker (@JavaProxy `AppwrapBackgroundWorker`) lives in
258
+ // `background-worker.android.ts` — see the import at the top of this file for WHY it can't be here.