@livx.cc/appwrap 0.19.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.
Files changed (113) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +20 -0
  3. package/package.json +46 -0
  4. package/runtime/App_Resources/Android/app.gradle +34 -0
  5. package/runtime/App_Resources/Android/before-plugins.gradle +15 -0
  6. package/runtime/App_Resources/Android/src/main/AndroidManifest.xml +76 -0
  7. package/runtime/App_Resources/Android/src/main/res/drawable/ic_launcher_foreground.xml +15 -0
  8. package/runtime/App_Resources/Android/src/main/res/drawable-hdpi/background.png +0 -0
  9. package/runtime/App_Resources/Android/src/main/res/drawable-hdpi/logo.png +0 -0
  10. package/runtime/App_Resources/Android/src/main/res/drawable-ldpi/background.png +0 -0
  11. package/runtime/App_Resources/Android/src/main/res/drawable-ldpi/logo.png +0 -0
  12. package/runtime/App_Resources/Android/src/main/res/drawable-mdpi/background.png +0 -0
  13. package/runtime/App_Resources/Android/src/main/res/drawable-mdpi/logo.png +0 -0
  14. package/runtime/App_Resources/Android/src/main/res/drawable-nodpi/splash_screen.xml +8 -0
  15. package/runtime/App_Resources/Android/src/main/res/drawable-xhdpi/background.png +0 -0
  16. package/runtime/App_Resources/Android/src/main/res/drawable-xhdpi/logo.png +0 -0
  17. package/runtime/App_Resources/Android/src/main/res/drawable-xxhdpi/background.png +0 -0
  18. package/runtime/App_Resources/Android/src/main/res/drawable-xxhdpi/logo.png +0 -0
  19. package/runtime/App_Resources/Android/src/main/res/drawable-xxxhdpi/background.png +0 -0
  20. package/runtime/App_Resources/Android/src/main/res/drawable-xxxhdpi/logo.png +0 -0
  21. package/runtime/App_Resources/Android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +5 -0
  22. package/runtime/App_Resources/Android/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
  23. package/runtime/App_Resources/Android/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
  24. package/runtime/App_Resources/Android/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
  25. package/runtime/App_Resources/Android/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
  26. package/runtime/App_Resources/Android/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
  27. package/runtime/App_Resources/Android/src/main/res/values/colors.xml +14 -0
  28. package/runtime/App_Resources/Android/src/main/res/values/ic_launcher_background.xml +4 -0
  29. package/runtime/App_Resources/Android/src/main/res/values/styles.xml +42 -0
  30. package/runtime/App_Resources/Android/src/main/res/values-v21/colors.xml +5 -0
  31. package/runtime/App_Resources/Android/src/main/res/values-v21/styles.xml +32 -0
  32. package/runtime/App_Resources/Android/src/main/res/values-v29/styles.xml +18 -0
  33. package/runtime/App_Resources/Android/src/main/res/xml/file_paths.xml +5 -0
  34. package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/Contents.json +122 -0
  35. package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-1024.png +0 -0
  36. package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-20.png +0 -0
  37. package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-20@2x.png +0 -0
  38. package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-20@3x.png +0 -0
  39. package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-29.png +0 -0
  40. package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-29@2x.png +0 -0
  41. package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-29@3x.png +0 -0
  42. package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-40.png +0 -0
  43. package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-40@2x.png +0 -0
  44. package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-40@3x.png +0 -0
  45. package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-60@2x.png +0 -0
  46. package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-60@3x.png +0 -0
  47. package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-76.png +0 -0
  48. package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-76@2x.png +0 -0
  49. package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-83.5@2x.png +0 -0
  50. package/runtime/App_Resources/iOS/Assets.xcassets/Contents.json +6 -0
  51. package/runtime/App_Resources/iOS/Assets.xcassets/LaunchScreen.AspectFill.imageset/Contents.json +23 -0
  52. package/runtime/App_Resources/iOS/Assets.xcassets/LaunchScreen.AspectFill.imageset/LaunchScreen-AspectFill.png +0 -0
  53. package/runtime/App_Resources/iOS/Assets.xcassets/LaunchScreen.AspectFill.imageset/LaunchScreen-AspectFill@2x.png +0 -0
  54. package/runtime/App_Resources/iOS/Assets.xcassets/LaunchScreen.AspectFill.imageset/LaunchScreen-AspectFill@3x.png +0 -0
  55. package/runtime/App_Resources/iOS/Assets.xcassets/LaunchScreen.Center.imageset/Contents.json +23 -0
  56. package/runtime/App_Resources/iOS/Assets.xcassets/LaunchScreen.Center.imageset/LaunchScreen-Center.png +0 -0
  57. package/runtime/App_Resources/iOS/Assets.xcassets/LaunchScreen.Center.imageset/LaunchScreen-Center@2x.png +0 -0
  58. package/runtime/App_Resources/iOS/Assets.xcassets/LaunchScreen.Center.imageset/LaunchScreen-Center@3x.png +0 -0
  59. package/runtime/App_Resources/iOS/Info.plist +60 -0
  60. package/runtime/App_Resources/iOS/LaunchScreen.storyboard +53 -0
  61. package/runtime/App_Resources/iOS/Podfile +13 -0
  62. package/runtime/App_Resources/iOS/PrivacyInfo.xcprivacy +59 -0
  63. package/runtime/App_Resources/iOS/build.xcconfig +7 -0
  64. package/runtime/app/app.ts +58 -0
  65. package/runtime/app/main-page.ts +116 -0
  66. package/runtime/app/main-page.xml +10 -0
  67. package/runtime/app/shell/active-modules.generated.ts +9 -0
  68. package/runtime/app/shell/android-helpers.ts +76 -0
  69. package/runtime/app/shell/bridge.ts +99 -0
  70. package/runtime/app/shell/capabilities.manifest.ts +207 -0
  71. package/runtime/app/shell/config.ts +35 -0
  72. package/runtime/app/shell/custom-webview.android.ts +270 -0
  73. package/runtime/app/shell/custom-webview.ios.ts +258 -0
  74. package/runtime/app/shell/custom-webview.ts +17 -0
  75. package/runtime/app/shell/env.ts +81 -0
  76. package/runtime/app/shell/events.ts +100 -0
  77. package/runtime/app/shell/fcm-bootstrap.generated.ts +1 -0
  78. package/runtime/app/shell/fcm-firebase.d.ts +16 -0
  79. package/runtime/app/shell/fcm-service.android.ts +40 -0
  80. package/runtime/app/shell/fcm-service.ios.ts +3 -0
  81. package/runtime/app/shell/handlers-android.ts +417 -0
  82. package/runtime/app/shell/handlers-billing.ts +197 -0
  83. package/runtime/app/shell/handlers-extended.ts +405 -0
  84. package/runtime/app/shell/handlers-health.ts +134 -0
  85. package/runtime/app/shell/handlers-media.ts +126 -0
  86. package/runtime/app/shell/handlers-oauth.ts +69 -0
  87. package/runtime/app/shell/handlers-parity.ts +257 -0
  88. package/runtime/app/shell/handlers-push.ts +189 -0
  89. package/runtime/app/shell/handlers-system.ts +70 -0
  90. package/runtime/app/shell/handlers.ts +149 -0
  91. package/runtime/app/shell/ios-frameworks.d.ts +36 -0
  92. package/runtime/app/shell/ios-image.ts +15 -0
  93. package/runtime/app/shell/ios-ui-delegate.ts +131 -0
  94. package/runtime/app/shell/mime.ts +29 -0
  95. package/runtime/app/shell/native-log.ts +22 -0
  96. package/runtime/app/shell/optional-handlers.generated.ts +10 -0
  97. package/runtime/app/shell/orientation.ts +43 -0
  98. package/runtime/app/shell/status-bar.ts +129 -0
  99. package/runtime/app/shell/toast.ts +67 -0
  100. package/runtime/app/shell/web-quirks.ts +131 -0
  101. package/runtime/modules-native/health/App_Resources/Android/src/main/java/cc/livx/appwrap/HealthConnectBridge.kt +74 -0
  102. package/runtime/nativescript.config.ts +15 -0
  103. package/runtime/package.json +21 -0
  104. package/runtime/references.d.ts +2 -0
  105. package/runtime/tsconfig.json +19 -0
  106. package/runtime/webpack.config.js +27 -0
  107. package/scripts/stage-assets.mjs +21 -0
  108. package/src/cli.ts +1328 -0
  109. package/templates/ci/fastlane/Fastfile +43 -0
  110. package/templates/ci/fastlane/Matchfile +3 -0
  111. package/templates/ci/github/workflows/appwrap-pr.yml +28 -0
  112. package/templates/ci/github/workflows/appwrap-release-android.yml +51 -0
  113. package/templates/ci/github/workflows/appwrap-release-ios.yml +41 -0
@@ -0,0 +1,405 @@
1
+ import { Application, ApplicationSettings, Connectivity, Device, Utils, isAndroid, isIOS } from '@nativescript/core';
2
+ import { bridge } from './bridge';
3
+ import { onDeepLink } from './events';
4
+ import { onRemoteMessage } from './handlers-push';
5
+ import { uiImageToDataUrl } from './ios-image';
6
+ import { maskForLock, setIosOrientationMask } from './orientation';
7
+
8
+ /**
9
+ * Extended capability handlers — iOS-first (Android arrives with the transport upgrade).
10
+ * Each handler is small and self-contained; heavy domains (push, files) come later.
11
+ */
12
+ export function registerExtendedHandlers(): void {
13
+ // ── device ─────────────────────────────────────────────────────────
14
+ bridge.register('device.info', () => {
15
+ let battery: { level: number; charging: boolean } | undefined;
16
+ if (isIOS) {
17
+ const dev = UIDevice.currentDevice;
18
+ dev.batteryMonitoringEnabled = true;
19
+ if (dev.batteryLevel >= 0) {
20
+ battery = {
21
+ level: dev.batteryLevel,
22
+ charging:
23
+ dev.batteryState === UIDeviceBatteryState.Charging ||
24
+ dev.batteryState === UIDeviceBatteryState.Full,
25
+ };
26
+ }
27
+ }
28
+ return {
29
+ model: Device.model,
30
+ os: Device.os,
31
+ osVersion: Device.osVersion,
32
+ language: Device.language,
33
+ region: Device.region,
34
+ manufacturer: Device.manufacturer,
35
+ battery,
36
+ };
37
+ });
38
+
39
+ // ── clipboard ──────────────────────────────────────────────────────
40
+ bridge.register('clipboard.copy', ({ text }: { text: string }) => {
41
+ if (isIOS) {
42
+ UIPasteboard.generalPasteboard.string = String(text ?? '');
43
+ } else if (isAndroid) {
44
+ const context = Utils.android.getApplicationContext();
45
+ const cm = context.getSystemService(android.content.Context.CLIPBOARD_SERVICE);
46
+ cm.setPrimaryClip(android.content.ClipData.newPlainText('appwrap', String(text ?? '')));
47
+ }
48
+ });
49
+ bridge.register('clipboard.read', () => {
50
+ if (isIOS) return UIPasteboard.generalPasteboard.string ?? null;
51
+ if (isAndroid) {
52
+ const context = Utils.android.getApplicationContext();
53
+ const cm = context.getSystemService(android.content.Context.CLIPBOARD_SERVICE);
54
+ const clip = cm.getPrimaryClip();
55
+ return clip && clip.getItemCount() > 0 ? String(clip.getItemAt(0).getText() ?? '') : null;
56
+ }
57
+ return null;
58
+ });
59
+
60
+ // ── secure storage (Keychain/Keystore via @nativescript/secure-storage) ──
61
+ const secure = new (require('@nativescript/secure-storage').SecureStorage)();
62
+ bridge.register('storage.secure.get', async ({ key }: { key: string }) =>
63
+ (await secure.get({ key: `kit:${key}` })) ?? null
64
+ );
65
+ bridge.register('storage.secure.set', ({ key, value }: { key: string; value: string }) =>
66
+ secure.set({ key: `kit:${key}`, value: String(value ?? '') }).then(() => undefined)
67
+ );
68
+ bridge.register('storage.secure.remove', ({ key }: { key: string }) =>
69
+ secure.remove({ key: `kit:${key}` }).then(() => undefined)
70
+ );
71
+
72
+ // ── notifications (UNUserNotificationCenter, iOS) ──────────────────
73
+ // iOS suppresses banners while the app is foreground unless a delegate opts in.
74
+ // Without this, a scheduled local notification fires but is never shown on-device.
75
+ installForegroundNotificationDelegate();
76
+
77
+ bridge.register('notifications.requestPermission', () => {
78
+ if (!isIOS) throw Object.assign(new Error('iOS only for now'), { code: 'UNSUPPORTED' });
79
+ return new Promise<string>((resolve) => {
80
+ const center = UNUserNotificationCenter.currentNotificationCenter();
81
+ center.requestAuthorizationWithOptionsCompletionHandler(
82
+ UNAuthorizationOptions.Alert | UNAuthorizationOptions.Badge | UNAuthorizationOptions.Sound,
83
+ (granted) => resolve(granted ? 'granted' : 'denied')
84
+ );
85
+ });
86
+ });
87
+
88
+ bridge.register('notifications.schedule', ({ id, title, body, delaySec, deepLink }: any) => {
89
+ if (!isIOS) throw Object.assign(new Error('iOS only for now'), { code: 'UNSUPPORTED' });
90
+ const nid = id ?? Math.floor(Math.random() * 100000);
91
+ return new Promise((resolve, reject) => {
92
+ const content = UNMutableNotificationContent.new();
93
+ content.title = String(title ?? '');
94
+ if (body) content.body = String(body);
95
+ // Carry the deep-link on the notification; the tap delegate re-emits it.
96
+ // Plain JS object → NativeScript marshals it to NSDictionary (more reliable
97
+ // than dictionaryWithObjectForKey across NS versions).
98
+ if (deepLink) content.userInfo = { url: String(deepLink) } as any;
99
+ const trigger = UNTimeIntervalNotificationTrigger.triggerWithTimeIntervalRepeats(
100
+ Math.max(1, delaySec ?? 1),
101
+ false
102
+ );
103
+ const request = UNNotificationRequest.requestWithIdentifierContentTrigger(
104
+ String(nid),
105
+ content,
106
+ trigger
107
+ );
108
+ UNUserNotificationCenter.currentNotificationCenter().addNotificationRequestWithCompletionHandler(
109
+ request,
110
+ (error) => (error ? reject(new Error(error.localizedDescription)) : resolve({ id: nid }))
111
+ );
112
+ });
113
+ });
114
+
115
+ bridge.register('notifications.pending', () => {
116
+ if (!isIOS) return 0;
117
+ return new Promise<number>((resolve) => {
118
+ UNUserNotificationCenter.currentNotificationCenter().getPendingNotificationRequestsWithCompletionHandler(
119
+ (requests) => resolve(requests.count)
120
+ );
121
+ });
122
+ });
123
+
124
+ bridge.register('notifications.setBadge', ({ count }: { count: number }) => {
125
+ if (!isIOS) return;
126
+ return new Promise<void>((resolve) => {
127
+ Utils.dispatchToMainThread(() => {
128
+ UNUserNotificationCenter.currentNotificationCenter().setBadgeCountWithCompletionHandler?.(
129
+ count,
130
+ () => {}
131
+ );
132
+ resolve();
133
+ });
134
+ });
135
+ });
136
+
137
+ bridge.register('notifications.clear', () => {
138
+ if (!isIOS) return;
139
+ const center = UNUserNotificationCenter.currentNotificationCenter();
140
+ center.removeAllPendingNotificationRequests();
141
+ center.removeAllDeliveredNotifications();
142
+ });
143
+
144
+ // ── biometrics (LAContext) ─────────────────────────────────────────
145
+ bridge.register('biometrics.available', () => {
146
+ if (!isIOS) return { available: false, type: 'none' };
147
+ const ctx = LAContext.new();
148
+ const available = ctx.canEvaluatePolicyError(
149
+ LAPolicy.DeviceOwnerAuthenticationWithBiometrics
150
+ );
151
+ const type =
152
+ ctx.biometryType === LABiometryType.FaceID ? 'face'
153
+ : ctx.biometryType === LABiometryType.TouchID ? 'touch'
154
+ : 'none';
155
+ return { available, type };
156
+ });
157
+
158
+ bridge.register('biometrics.authenticate', ({ reason }: { reason?: string }) => {
159
+ if (!isIOS) throw Object.assign(new Error('iOS only for now'), { code: 'UNSUPPORTED' });
160
+ return new Promise((resolve, reject) => {
161
+ const ctx = LAContext.new();
162
+ ctx.evaluatePolicyLocalizedReasonReply(
163
+ LAPolicy.DeviceOwnerAuthenticationWithBiometrics,
164
+ String(reason ?? 'Authenticate'),
165
+ (success, error) => {
166
+ if (success) resolve({ success: true });
167
+ else reject(Object.assign(new Error(error?.localizedDescription ?? 'failed'), { code: 'DENIED' }));
168
+ }
169
+ );
170
+ });
171
+ });
172
+
173
+ // ── geolocation (CLLocationManager) ────────────────────────────────
174
+ bridge.register('geo.current', () => {
175
+ if (!isIOS) throw Object.assign(new Error('iOS only for now'), { code: 'UNSUPPORTED' });
176
+ return new Promise((resolve, reject) => {
177
+ const timer = setTimeout(() => {
178
+ cleanup();
179
+ reject(Object.assign(new Error('location timeout'), { code: 'TIMEOUT' }));
180
+ }, 15000);
181
+
182
+ let manager: CLLocationManager | null = null;
183
+ let delegate: any = null;
184
+ const cleanup = () => {
185
+ clearTimeout(timer);
186
+ manager?.stopUpdatingLocation();
187
+ manager = null;
188
+ delegate = null;
189
+ };
190
+
191
+ const DelegateClass = (NSObject as any).extend(
192
+ {
193
+ locationManagerDidUpdateLocations(_m: CLLocationManager, locations: NSArray<CLLocation>) {
194
+ const loc = locations.lastObject;
195
+ if (!loc) return;
196
+ const result = {
197
+ lat: loc.coordinate.latitude,
198
+ lng: loc.coordinate.longitude,
199
+ accuracy: loc.horizontalAccuracy,
200
+ };
201
+ cleanup();
202
+ resolve(result);
203
+ },
204
+ locationManagerDidFailWithError(_m: CLLocationManager, error: NSError) {
205
+ cleanup();
206
+ reject(Object.assign(new Error(error.localizedDescription), { code: 'DENIED' }));
207
+ },
208
+ locationManagerDidChangeAuthorization(m: CLLocationManager) {
209
+ const st = m.authorizationStatus;
210
+ if (st === CLAuthorizationStatus.kCLAuthorizationStatusAuthorizedWhenInUse ||
211
+ st === CLAuthorizationStatus.kCLAuthorizationStatusAuthorizedAlways) {
212
+ m.startUpdatingLocation();
213
+ } else if (st === CLAuthorizationStatus.kCLAuthorizationStatusDenied) {
214
+ cleanup();
215
+ reject(Object.assign(new Error('location permission denied'), { code: 'DENIED' }));
216
+ }
217
+ },
218
+ },
219
+ { protocols: [CLLocationManagerDelegate] }
220
+ );
221
+
222
+ Utils.dispatchToMainThread(() => {
223
+ manager = CLLocationManager.new();
224
+ delegate = DelegateClass.new();
225
+ manager.delegate = delegate;
226
+ manager.desiredAccuracy = kCLLocationAccuracyHundredMeters;
227
+ const st = manager.authorizationStatus;
228
+ if (st === CLAuthorizationStatus.kCLAuthorizationStatusNotDetermined) {
229
+ manager.requestWhenInUseAuthorization();
230
+ } else {
231
+ manager.startUpdatingLocation();
232
+ }
233
+ });
234
+ });
235
+ });
236
+
237
+ // ── photos (PHPickerViewController) ────────────────────────────────
238
+ bridge.register('photos.pick', ({ dataUrl, maxSize }: any = {}) => {
239
+ if (!isIOS) throw Object.assign(new Error('iOS only for now'), { code: 'UNSUPPORTED' });
240
+ return new Promise((resolve) => {
241
+ Utils.dispatchToMainThread(() => {
242
+ const config = PHPickerConfiguration.new();
243
+ config.selectionLimit = 1;
244
+ const DelegateClass = (NSObject as any).extend(
245
+ {
246
+ pickerDidFinishPicking(picker: PHPickerViewController, results: NSArray<PHPickerResult>) {
247
+ picker.dismissViewControllerAnimatedCompletion(true, null);
248
+ const result = results.count > 0 ? results.objectAtIndex(0) : null;
249
+ if (!result) return resolve({ picked: false });
250
+ result.itemProvider.loadObjectOfClassCompletionHandler(UIImage.class(), (img: UIImage) => {
251
+ if (!img) return resolve({ picked: true });
252
+ const out: any = { picked: true, width: img.size.width, height: img.size.height };
253
+ if (dataUrl) out.dataUrl = uiImageToDataUrl(img, maxSize ?? 1024);
254
+ resolve(out);
255
+ });
256
+ },
257
+ },
258
+ { protocols: [PHPickerViewControllerDelegate] }
259
+ );
260
+ const picker = PHPickerViewController.alloc().initWithConfiguration(config);
261
+ const delegate = DelegateClass.new();
262
+ (picker as any)._appwrapDelegate = delegate; // retain
263
+ picker.delegate = delegate;
264
+ Utils.ios.getRootViewController().presentViewControllerAnimatedCompletion(picker, true, null);
265
+ });
266
+ });
267
+ });
268
+
269
+ // ── network (NS Connectivity) ──────────────────────────────────────
270
+ bridge.register('network.status', () => connectivityStatus());
271
+
272
+ // ── ui extras ──────────────────────────────────────────────────────
273
+ bridge.register('ui.safeArea', () => {
274
+ if (!isIOS) return { top: 0, bottom: 0, left: 0, right: 0 };
275
+ const insets = Utils.ios.getRootViewController().view.safeAreaInsets;
276
+ return { top: insets.top, bottom: insets.bottom, left: insets.left, right: insets.right };
277
+ });
278
+
279
+ bridge.register('ui.brightness.get', () => (isIOS ? UIScreen.mainScreen.brightness : 0.5));
280
+ bridge.register('ui.brightness.set', ({ level }: { level: number }) => {
281
+ if (isIOS) {
282
+ Utils.dispatchToMainThread(() => {
283
+ UIScreen.mainScreen.brightness = Math.max(0, Math.min(1, level));
284
+ });
285
+ }
286
+ });
287
+
288
+ bridge.register('ui.keepAwake', ({ on }: { on: boolean }) => {
289
+ if (isIOS) {
290
+ Utils.dispatchToMainThread(() => {
291
+ UIApplication.sharedApplication.idleTimerDisabled = !!on;
292
+ });
293
+ }
294
+ });
295
+
296
+ // ── screen orientation ─────────────────────────────────────────────
297
+ // current() is cross-platform via core; lock/unlock are iOS here and
298
+ // overridden for Android in registerAndroidHandlers (runs last).
299
+ bridge.register('screen.orientation.current', () =>
300
+ Application.orientation() === 'landscape' ? 'landscape' : 'portrait'
301
+ );
302
+ bridge.register('screen.orientation.lock', ({ orientation }: { orientation: string }) => {
303
+ if (isIOS) applyIosOrientation(maskForLock(String(orientation)));
304
+ });
305
+ bridge.register('screen.orientation.unlock', () => {
306
+ if (isIOS) applyIosOrientation(maskForLock('any'));
307
+ });
308
+ }
309
+
310
+ /** Set the supported-orientation mask and force UIKit to re-evaluate it now (iOS 16+ geometry update). */
311
+ function applyIosOrientation(mask: number): void {
312
+ setIosOrientationMask(mask);
313
+ Utils.dispatchToMainThread(() => {
314
+ const rootVC: any = Utils.ios.getRootViewController();
315
+ rootVC?.setNeedsUpdateOfSupportedInterfaceOrientations?.();
316
+ const G: any = global as any;
317
+ const Prefs = G.UIWindowSceneGeometryPreferencesIOS;
318
+ const scenes: any = UIApplication.sharedApplication.connectedScenes?.allObjects;
319
+ if (!Prefs || !scenes) return; // pre-iOS-16: the mask alone takes effect on next rotation
320
+ for (let i = 0; i < scenes.count; i++) {
321
+ const scene = scenes.objectAtIndex(i);
322
+ if (scene?.requestGeometryUpdateWithPreferencesErrorHandler) {
323
+ const prefs = Prefs.alloc().initWithInterfaceOrientations(mask);
324
+ scene.requestGeometryUpdateWithPreferencesErrorHandler(prefs, null);
325
+ }
326
+ }
327
+ });
328
+ }
329
+
330
+ let notifDelegate: any = null; // retained for the app's lifetime
331
+ /**
332
+ * Install the UNUserNotificationCenter delegate. MUST run before
333
+ * `didFinishLaunching` returns, else a tap that COLD-LAUNCHES the app never
334
+ * reaches `didReceiveNotificationResponse` (Apple delivers it only to a
335
+ * delegate set during launch) — the deep link is lost and you land on home.
336
+ * Idempotent; also called from registerExtendedHandlers as a warm fallback.
337
+ */
338
+ export function installForegroundNotificationDelegate(): void {
339
+ if (!isIOS || notifDelegate) return;
340
+ const DelegateClass = (NSObject as any).extend(
341
+ {
342
+ userNotificationCenterWillPresentNotificationWithCompletionHandler(
343
+ _center: UNUserNotificationCenter,
344
+ notification: UNNotification,
345
+ completionHandler: (opts: number) => void
346
+ ): void {
347
+ // Foreground REMOTE push (push trigger) → surface to kit.push.onMessage.
348
+ if (isRemotePush(notification)) onRemoteMessage(notification.request.content.userInfo, false);
349
+ // Present banner + list + sound + badge even while foreground (min target iOS 16).
350
+ completionHandler(
351
+ UNNotificationPresentationOptions.Banner |
352
+ UNNotificationPresentationOptions.List |
353
+ UNNotificationPresentationOptions.Sound |
354
+ UNNotificationPresentationOptions.Badge
355
+ );
356
+ },
357
+ // Notification tapped → route its deep link through the same path as an external open.
358
+ userNotificationCenterDidReceiveNotificationResponseWithCompletionHandler(
359
+ _center: UNUserNotificationCenter,
360
+ response: UNNotificationResponse,
361
+ completionHandler: () => void
362
+ ): void {
363
+ // Tapped REMOTE push → kit.push.onTap (with payload). Local notifs keep the deep-link path below.
364
+ if (isRemotePush(response.notification)) onRemoteMessage(response.notification.request.content.userInfo, true);
365
+ // userInfo may come back as an NSDictionary or an auto-marshalled JS object.
366
+ const info: any = response.notification.request.content.userInfo;
367
+ const url = info ? (typeof info.objectForKey === 'function' ? info.objectForKey('url') : info.url) : null;
368
+ // Diagnostic breadcrumb (persists across the cold relaunch) — surfaced in the handshake's debug field.
369
+ try {
370
+ ApplicationSettings.setString(
371
+ 'kit:__notifTap',
372
+ JSON.stringify({ at: Date.now(), url: url ? String(url) : null, hadInfo: !!info })
373
+ );
374
+ } catch {
375
+ /* diagnostic only */
376
+ }
377
+ if (url) onDeepLink(String(url));
378
+ completionHandler();
379
+ },
380
+ },
381
+ { protocols: [UNUserNotificationCenterDelegate] }
382
+ );
383
+ notifDelegate = DelegateClass.new();
384
+ UNUserNotificationCenter.currentNotificationCenter().delegate = notifDelegate;
385
+ }
386
+
387
+ /** A remote (APNs) notification has a UNPushNotificationTrigger; local ones have a time/calendar trigger. */
388
+ function isRemotePush(notification: UNNotification): boolean {
389
+ const trigger = notification?.request?.trigger;
390
+ return !!trigger && trigger instanceof UNPushNotificationTrigger;
391
+ }
392
+
393
+ export function connectivityStatus(): { online: boolean; type: string } {
394
+ const t = Connectivity.getConnectionType();
395
+ const map: Record<number, string> = {
396
+ [Connectivity.connectionType.none]: 'none',
397
+ [Connectivity.connectionType.wifi]: 'wifi',
398
+ [Connectivity.connectionType.mobile]: 'cellular',
399
+ [Connectivity.connectionType.ethernet]: 'ethernet',
400
+ [Connectivity.connectionType.bluetooth]: 'bluetooth',
401
+ [Connectivity.connectionType.vpn]: 'vpn',
402
+ };
403
+ const type = map[t] ?? 'unknown';
404
+ return { online: type !== 'none', type };
405
+ }
@@ -0,0 +1,134 @@
1
+ import { Utils, isAndroid, isIOS } from '@nativescript/core';
2
+ import { bridge } from './bridge';
3
+ import { requestPermissions, startActivityForResult } from './android-helpers';
4
+
5
+ declare const android: any;
6
+ declare const cc: any; // generated from the HealthConnectBridge.kt shim (overrides/)
7
+
8
+ const err = (code: string, message: string) => Object.assign(new Error(message), { code });
9
+
10
+ /** Step counting from the platform health store — today's total, global, survives an app kill.
11
+ * iOS: HealthKit. Android: Health Connect (via the HealthConnectBridge.kt shim) with a
12
+ * SensorManager TYPE_STEP_COUNTER fallback when Health Connect isn't available. */
13
+ export function registerHealthHandlers(): void {
14
+ if (isIOS) registerIos();
15
+ else if (isAndroid) registerAndroid();
16
+ }
17
+
18
+ /** NSNumber/JS-number tolerant read. */
19
+ function num(v: any): number {
20
+ if (typeof v === 'number') return v;
21
+ if (v && typeof v.doubleValue === 'number') return v.doubleValue; // NSNumber
22
+ const n = Number(v);
23
+ return Number.isFinite(n) ? n : 0;
24
+ }
25
+
26
+ function registerIos(): void {
27
+ // HealthKit reads the Health app's AGGREGATED step total (iPhone + Apple Watch + other sources) —
28
+ // recorded by the OS regardless of the app, so it's global and survives kill. Count = today's sum.
29
+ const store = HKHealthStore.new();
30
+ const stepType = HKObjectType.quantityTypeForIdentifier('HKQuantityTypeIdentifierStepCount');
31
+ const startOfToday = () => {
32
+ const d = new Date();
33
+ d.setHours(0, 0, 0, 0); // local midnight
34
+ return NSDate.dateWithTimeIntervalSince1970(d.getTime() / 1000);
35
+ };
36
+
37
+ bridge.register('health.requestAccess', () =>
38
+ new Promise((resolve) => {
39
+ if (!HKHealthStore.isHealthDataAvailable()) { resolve(false); return; }
40
+ const readSet = NSSet.setWithObject(stepType);
41
+ store.requestAuthorizationToShareTypesReadTypesCompletion(null, readSet, (ok: boolean, e: any) => {
42
+ Utils.dispatchToMainThread(() => resolve(!!ok && !e));
43
+ });
44
+ })
45
+ );
46
+
47
+ bridge.register('health.start', () => {
48
+ if (!HKHealthStore.isHealthDataAvailable()) throw err('UNSUPPORTED', 'HealthKit unavailable on this device');
49
+ });
50
+ bridge.register('health.stop', () => {});
51
+
52
+ // HKStatisticsQuery completion fires off-main — settle the bridge promise on the main thread so the
53
+ // WKWebView response delivery (evaluateJavaScript) is main-thread.
54
+ bridge.register('health.count', () =>
55
+ new Promise((resolve, reject) => {
56
+ const predicate = HKQuery.predicateForSamplesWithStartDateEndDateOptions(startOfToday(), NSDate.date(), 0);
57
+ const q = HKStatisticsQuery.alloc().initWithQuantityTypeQuantitySamplePredicateOptionsCompletionHandler(
58
+ stepType, predicate, HKStatisticsOptions.CumulativeSum, (_q: any, stats: any, e: any) => {
59
+ Utils.dispatchToMainThread(() => {
60
+ if (e) { reject(err('NATIVE_ERROR', e.localizedDescription ?? 'health query failed')); return; }
61
+ const sum = stats && stats.sumQuantity ? stats.sumQuantity() : null;
62
+ resolve({ steps: sum ? Math.round(sum.doubleValueForUnit(HKUnit.countUnit())) : 0 });
63
+ });
64
+ }
65
+ );
66
+ store.executeQuery(q);
67
+ })
68
+ );
69
+ }
70
+
71
+ function registerAndroid(): void {
72
+ const ctx = () => Utils.android.getApplicationContext();
73
+ const HCB = () => cc.livx.appwrap.HealthConnectBridge;
74
+ // Primary path = Health Connect (system store, Wear-inclusive, survives kill). Cached once.
75
+ let hc: boolean | null = null;
76
+ const hcAvailable = () => {
77
+ if (hc == null) {
78
+ try { hc = !!HCB().isAvailable(ctx()); } catch (e) { hc = false; }
79
+ }
80
+ return hc;
81
+ };
82
+
83
+ // ── sensor fallback (TYPE_STEP_COUNTER) — when Health Connect isn't available ──
84
+ let sensorManager: any = null;
85
+ let listener: any = null;
86
+ let baseline: number | null = null;
87
+ let latest: number | null = null;
88
+ let registered = false;
89
+ const ensureSensor = () => {
90
+ if (!sensorManager) sensorManager = ctx().getSystemService(android.content.Context.SENSOR_SERVICE);
91
+ };
92
+ const stopSensor = () => {
93
+ if (registered) { sensorManager.unregisterListener(listener); registered = false; }
94
+ };
95
+ const startSensor = () => {
96
+ ensureSensor();
97
+ const sensor = sensorManager.getDefaultSensor(android.hardware.Sensor.TYPE_STEP_COUNTER);
98
+ if (!sensor) throw err('UNSUPPORTED', 'No step-counter sensor on this device');
99
+ stopSensor();
100
+ baseline = null;
101
+ latest = null;
102
+ listener = new android.hardware.SensorEventListener({
103
+ onSensorChanged: (ev: any) => { const v = num(ev.values[0]); latest = v; if (baseline == null) baseline = v; },
104
+ onAccuracyChanged: () => {},
105
+ });
106
+ sensorManager.registerListener(listener, sensor, android.hardware.SensorManager.SENSOR_DELAY_NORMAL);
107
+ registered = true;
108
+ };
109
+ const sensorCount = () => ({ steps: baseline != null && latest != null ? Math.max(0, Math.round(latest - baseline)) : 0 });
110
+
111
+ bridge.register('health.requestAccess', async () => {
112
+ if (hcAvailable()) {
113
+ // Launch the Health Connect permission UI, then re-check what was granted.
114
+ await startActivityForResult(HCB().permissionIntent(ctx())).catch((e) => console.warn('[health] HC permission intent failed', e));
115
+ return new Promise((resolve) =>
116
+ HCB().hasPermission(ctx(), new (HCB().BoolCallback)({ onResult: (g: boolean) => resolve(!!g) }))
117
+ );
118
+ }
119
+ return requestPermissions(['android.permission.ACTIVITY_RECOGNITION']);
120
+ });
121
+
122
+ bridge.register('health.start', () => { if (!hcAvailable()) startSensor(); });
123
+ bridge.register('health.stop', () => { if (!hcAvailable()) stopSensor(); });
124
+
125
+ bridge.register('health.count', () => {
126
+ if (hcAvailable()) {
127
+ // Health Connect: today's aggregated total (dedupes across sources incl. Wear).
128
+ return new Promise((resolve) =>
129
+ HCB().readTodaySteps(ctx(), new (HCB().LongCallback)({ onResult: (v: any) => { const n = Number(v); resolve({ steps: n > 0 ? n : 0 }); } }))
130
+ );
131
+ }
132
+ return sensorCount();
133
+ });
134
+ }
@@ -0,0 +1,126 @@
1
+ import { Utils, isIOS } from '@nativescript/core';
2
+ import { bridge } from './bridge';
3
+
4
+ // AVAudioSession globals (NS resolves these from the AVFoundation metadata at runtime).
5
+ declare const AVAudioSession: any;
6
+ declare const AVAudioSessionCategoryPlayAndRecord: string;
7
+ declare const AVAudioSessionPortBuiltInReceiver: string;
8
+ declare const AVAudioSessionRouteChangeNotification: string;
9
+ declare const AVAudioSessionPortOverride: any;
10
+ declare const NSNotificationCenter: any;
11
+
12
+ let routeObserverArmed = false;
13
+
14
+ /** List the current output port types (e.g. "Receiver", "Speaker", "BluetoothA2DP"). */
15
+ function outputPortTypes(session: any): string[] {
16
+ const outputs = session.currentRoute?.outputs;
17
+ const types: string[] = [];
18
+ for (let i = 0; i < (outputs?.count ?? 0); i++) types.push(String(outputs.objectAtIndex(i).portType));
19
+ return types;
20
+ }
21
+
22
+ function isOnBuiltInReceiver(session: any): boolean {
23
+ return outputPortTypes(session).indexOf(AVAudioSessionPortBuiltInReceiver) !== -1;
24
+ }
25
+
26
+ /** Whether the session can record (PlayAndRecord) — i.e. we're (or about to be) in a call. */
27
+ function isRecordCategory(session: any): boolean {
28
+ return String(session.category) === String(AVAudioSessionCategoryPlayAndRecord);
29
+ }
30
+
31
+ /** Push output to the loudspeaker while a call route (PlayAndRecord) is on the built-in receiver. */
32
+ function forceSpeakerIfReceiverDuringCall(): void {
33
+ const session = AVAudioSession.sharedInstance();
34
+ if (!isRecordCategory(session) || !isOnBuiltInReceiver(session)) return; // not a call, or already speaker/headset/BT
35
+ try {
36
+ session.overrideOutputAudioPortError(AVAudioSessionPortOverride.Speaker, null);
37
+ } catch {
38
+ /* benign — route may have changed again under us */
39
+ }
40
+ }
41
+
42
+ /** Observe route changes once; re-assert loudspeaker whenever a call route drops to the receiver. */
43
+ function observeAudioRouteForSpeaker(): void {
44
+ if (routeObserverArmed) return;
45
+ routeObserverArmed = true;
46
+ NSNotificationCenter.defaultCenter.addObserverForNameObjectQueueUsingBlock(
47
+ AVAudioSessionRouteChangeNotification,
48
+ null,
49
+ null,
50
+ () => Utils.dispatchToMainThread(() => forceSpeakerIfReceiverDuringCall())
51
+ );
52
+ }
53
+
54
+ /**
55
+ * Live-media session config. getUserMedia / playback themselves are pure WebView
56
+ * APIs (unlocked by the UIDelegate / onPermissionRequest grants in the custom
57
+ * webviews); this only tunes the native *audio session*, which iOS needs to play
58
+ * over the silent switch and to route mic+speaker for calls. Android routes
59
+ * fine on its own — honest no-op there.
60
+ */
61
+ export function registerMediaHandlers(): void {
62
+ // DIAGNOSTIC: surface the WKWebView UIDelegate status to the page (on-screen log).
63
+ bridge.register('debug.webviewInfo', () => ({ diag: (global as any).__appwrapWebviewDiag ?? 'n/a (android/web)' }));
64
+
65
+ // Auto loudspeaker for in-WebView WebRTC calls. When getUserMedia is live, WebKit drives the
66
+ // shared AVAudioSession into PlayAndRecord and routes playback to the *earpiece/receiver* at
67
+ // call-gain — so remote audio sounds quiet even at max volume. We observe route changes and, while
68
+ // a capture (PlayAndRecord) session is active and the output fell back to the receiver, override
69
+ // the output port to the loudspeaker. Scoped to PlayAndRecord so non-call playback is untouched;
70
+ // honors a connected headset/Bluetooth route (only the built-in receiver is redirected).
71
+ if (isIOS) observeAudioRouteForSpeaker();
72
+
73
+ // Establish the OS-level (TCC) mic/camera grant from the *app* process. WKWebView's
74
+ // getUserMedia checks the same per-app TCC entry but doesn't persist a grant of its
75
+ // own, so without this it re-prompts on every call. AVCaptureDevice.requestAccess
76
+ // prompts once and the grant sticks — after which the WebView capture is silent.
77
+ bridge.register('media.ensurePermission', async ({ audio, video }: { audio?: boolean; video?: boolean } = {}) => {
78
+ if (!isIOS) return { audio: 'granted', video: 'granted' }; // Android: onPermissionRequest handles it
79
+
80
+ const ask = (mediaType: string): Promise<string> =>
81
+ new Promise((resolve) => {
82
+ const status = AVCaptureDevice.authorizationStatusForMediaType(mediaType);
83
+ if (status === AVAuthorizationStatus.Authorized) return resolve('granted');
84
+ if (status === AVAuthorizationStatus.Denied || status === AVAuthorizationStatus.Restricted) {
85
+ return resolve('denied');
86
+ }
87
+ AVCaptureDevice.requestAccessForMediaTypeCompletionHandler(mediaType, (granted: boolean) =>
88
+ resolve(granted ? 'granted' : 'denied')
89
+ );
90
+ });
91
+
92
+ const out: { audio?: string; video?: string } = {};
93
+ if (audio) out.audio = await ask(AVMediaTypeAudio);
94
+ if (video) out.video = await ask(AVMediaTypeVideo);
95
+ return out;
96
+ });
97
+
98
+ bridge.register('media.configureAudio', ({ mode }: { mode?: string }) => {
99
+ if (!isIOS) return; // Android: nothing to configure
100
+ const session = AVAudioSession.sharedInstance();
101
+ let category: string;
102
+ let options = 0;
103
+ // Default mode = the system's choice for the category; voiceChat swaps in the
104
+ // voice-processing I/O unit (hardware AEC) so TTS doesn't bleed into the mic.
105
+ let avMode = AVAudioSessionModeDefault;
106
+ if (mode === 'playAndRecord') {
107
+ category = AVAudioSessionCategoryPlayAndRecord;
108
+ // Loud speaker by default + allow Bluetooth headsets for calls.
109
+ options =
110
+ AVAudioSessionCategoryOptions.DefaultToSpeaker | AVAudioSessionCategoryOptions.AllowBluetooth;
111
+ } else if (mode === 'voiceChat') {
112
+ // Full-duplex voice agent: PlayAndRecord + VoiceChat engages iOS acoustic
113
+ // echo cancellation, so speaker TTS is removed from the mic feed (no STT bleed).
114
+ category = AVAudioSessionCategoryPlayAndRecord;
115
+ options =
116
+ AVAudioSessionCategoryOptions.DefaultToSpeaker | AVAudioSessionCategoryOptions.AllowBluetooth;
117
+ avMode = AVAudioSessionModeVoiceChat;
118
+ } else if (mode === 'default') {
119
+ category = AVAudioSessionCategoryAmbient;
120
+ } else {
121
+ category = AVAudioSessionCategoryPlayback; // ignores the silent switch
122
+ }
123
+ session.setCategoryModeOptionsError(category, avMode, options as any, null as any);
124
+ session.setActiveError(true, null as any);
125
+ });
126
+ }