@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,417 @@
1
+ import { Application, Utils } from '@nativescript/core';
2
+ import { bridge } from './bridge';
3
+ import { requestPermissions, startActivityForResult, uriToDataUrl, bitmapToDataUrl } from './android-helpers';
4
+
5
+ declare const android: any, java: any, androidx: any;
6
+
7
+ const err = (code: string, message: string) => Object.assign(new Error(message), { code });
8
+
9
+ const CHANNEL_ID = 'appwrap';
10
+
11
+ /**
12
+ * Android implementations for the iOS-first domains. Registered AFTER the other
13
+ * handler sets (bridge.register overwrites), so each method here replaces its
14
+ * `throw iosOnly()` placeholder on Android only. Call only when isAndroid.
15
+ */
16
+ export function registerAndroidHandlers(): void {
17
+ const context = () => Utils.android.getApplicationContext();
18
+ const activity = () => Application.android.foregroundActivity ?? Application.android.startActivity;
19
+
20
+ // ── notifications (NotificationManager + channel) ──────────────────
21
+ const notificationManager = () =>
22
+ context().getSystemService(android.content.Context.NOTIFICATION_SERVICE);
23
+ const pendingTimers = new Map<number, any>();
24
+
25
+ const ensureChannel = () => {
26
+ if (android.os.Build.VERSION.SDK_INT < 26) return;
27
+ const channel = new android.app.NotificationChannel(
28
+ CHANNEL_ID, 'Notifications', android.app.NotificationManager.IMPORTANCE_DEFAULT
29
+ );
30
+ notificationManager().createNotificationChannel(channel);
31
+ };
32
+
33
+ bridge.register('notifications.requestPermission', async () => {
34
+ if (android.os.Build.VERSION.SDK_INT < 33) return 'granted';
35
+ const ok = await requestPermissions(['android.permission.POST_NOTIFICATIONS']);
36
+ return ok ? 'granted' : 'denied';
37
+ });
38
+
39
+ bridge.register('notifications.schedule', ({ id, title, body, delaySec, deepLink }: any) => {
40
+ ensureChannel();
41
+ const nid = id ?? Math.floor(Math.random() * 100000);
42
+ const timer = setTimeout(() => {
43
+ pendingTimers.delete(nid);
44
+ const ctx = context();
45
+ const builder = android.os.Build.VERSION.SDK_INT >= 26
46
+ ? new android.app.Notification.Builder(ctx, CHANNEL_ID)
47
+ : new android.app.Notification.Builder(ctx);
48
+ builder
49
+ .setSmallIcon(ctx.getApplicationInfo().icon)
50
+ .setContentTitle(String(title ?? ''))
51
+ .setAutoCancel(true);
52
+ if (body) builder.setContentText(String(body));
53
+ // Tap → re-open the (singleTask) activity with a VIEW intent; onNewIntent
54
+ // routes it through the same deep-link path as an external open.
55
+ if (deepLink) {
56
+ const viewIntent = new android.content.Intent(
57
+ android.content.Intent.ACTION_VIEW, android.net.Uri.parse(String(deepLink))
58
+ );
59
+ viewIntent.setPackage(ctx.getPackageName());
60
+ viewIntent.addFlags(
61
+ android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP | android.content.Intent.FLAG_ACTIVITY_NEW_TASK
62
+ );
63
+ const piFlags = android.os.Build.VERSION.SDK_INT >= 23
64
+ ? android.app.PendingIntent.FLAG_IMMUTABLE | android.app.PendingIntent.FLAG_UPDATE_CURRENT
65
+ : android.app.PendingIntent.FLAG_UPDATE_CURRENT;
66
+ builder.setContentIntent(android.app.PendingIntent.getActivity(ctx, nid, viewIntent, piFlags));
67
+ }
68
+ notificationManager().notify(nid, builder.build());
69
+ }, Math.max(1, delaySec ?? 1) * 1000);
70
+ pendingTimers.set(nid, timer);
71
+ return { id: nid };
72
+ });
73
+
74
+ bridge.register('notifications.pending', () => pendingTimers.size);
75
+
76
+ bridge.register('notifications.setBadge', () => {
77
+ // No portable badge API on Android — launchers own badges. Honest no-op.
78
+ });
79
+
80
+ bridge.register('notifications.clear', () => {
81
+ for (const timer of pendingTimers.values()) clearTimeout(timer);
82
+ pendingTimers.clear();
83
+ notificationManager().cancelAll();
84
+ });
85
+
86
+ // ── biometrics (androidx.biometric BiometricPrompt) ────────────────
87
+ bridge.register('biometrics.available', () => {
88
+ try {
89
+ const manager = androidx.biometric.BiometricManager.from(context());
90
+ const ok = manager.canAuthenticate(androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK)
91
+ === androidx.biometric.BiometricManager.BIOMETRIC_SUCCESS;
92
+ return { available: ok, type: ok ? 'touch' : 'none' };
93
+ } catch (e) {
94
+ console.warn('AppWrap: biometrics.available failed', e);
95
+ return { available: false, type: 'none' };
96
+ }
97
+ });
98
+
99
+ bridge.register('biometrics.authenticate', ({ reason }: { reason?: string }) => {
100
+ return new Promise((resolve, reject) => {
101
+ Utils.dispatchToMainThread(() => {
102
+ try {
103
+ const act = activity(); // NativeScriptActivity extends AppCompatActivity → FragmentActivity
104
+ const executor = androidx.core.content.ContextCompat.getMainExecutor(act);
105
+ const Callback = (androidx.biometric.BiometricPrompt.AuthenticationCallback as any).extend({
106
+ onAuthenticationSucceeded(_result: any) { resolve({ success: true }); },
107
+ onAuthenticationError(_code: number, message: any) {
108
+ reject(err('DENIED', String(message ?? 'authentication error')));
109
+ },
110
+ });
111
+ const prompt = new androidx.biometric.BiometricPrompt(act, executor, new Callback());
112
+ const info = new androidx.biometric.BiometricPrompt.PromptInfo.Builder()
113
+ .setTitle(String(reason ?? 'Authenticate'))
114
+ .setAllowedAuthenticators(androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK)
115
+ .setNegativeButtonText('Cancel')
116
+ .build();
117
+ prompt.authenticate(info);
118
+ } catch (e: any) {
119
+ reject(err('UNSUPPORTED', e?.message ?? String(e)));
120
+ }
121
+ });
122
+ });
123
+ });
124
+
125
+ // ── geolocation (LocationManager) ──────────────────────────────────
126
+ const GEO_PERMS = ['android.permission.ACCESS_FINE_LOCATION', 'android.permission.ACCESS_COARSE_LOCATION'];
127
+ const locationManager = () => context().getSystemService(android.content.Context.LOCATION_SERVICE);
128
+ const toPosition = (loc: any) => ({
129
+ lat: loc.getLatitude(), lng: loc.getLongitude(), accuracy: loc.getAccuracy(),
130
+ });
131
+ const makeListener = (onLocation: (loc: any) => void) =>
132
+ new android.location.LocationListener({
133
+ // Both overloads dispatch here — newer Android may deliver a List<Location>
134
+ onLocationChanged(loc: any) {
135
+ if (loc && typeof loc.getLatitude !== 'function' && typeof loc.size === 'function') {
136
+ loc = loc.size() > 0 ? loc.get(loc.size() - 1) : null;
137
+ }
138
+ if (loc) onLocation(loc);
139
+ },
140
+ onStatusChanged() {}, onProviderEnabled() {}, onProviderDisabled() {},
141
+ });
142
+
143
+ bridge.register('geo.current', async () => {
144
+ if (!(await requestPermissions(GEO_PERMS))) throw err('DENIED', 'location permission denied');
145
+ const lm = locationManager();
146
+ return new Promise((resolve, reject) => {
147
+ const last = lm.getLastKnownLocation(android.location.LocationManager.GPS_PROVIDER)
148
+ ?? lm.getLastKnownLocation(android.location.LocationManager.NETWORK_PROVIDER);
149
+ if (last) return resolve(toPosition(last));
150
+
151
+ let listener: any = null;
152
+ const timer = setTimeout(() => {
153
+ if (listener) lm.removeUpdates(listener);
154
+ reject(err('TIMEOUT', 'location timeout'));
155
+ }, 15000);
156
+ listener = makeListener((loc) => {
157
+ clearTimeout(timer);
158
+ lm.removeUpdates(listener);
159
+ resolve(toPosition(loc));
160
+ });
161
+ Utils.dispatchToMainThread(() => {
162
+ lm.requestLocationUpdates(android.location.LocationManager.GPS_PROVIDER, 0, 0, listener,
163
+ android.os.Looper.getMainLooper());
164
+ });
165
+ });
166
+ });
167
+
168
+ let geoWatchListener: any = null;
169
+ bridge.register('geo.watch.start', async () => {
170
+ if (geoWatchListener) return; // already streaming
171
+ if (!(await requestPermissions(GEO_PERMS))) throw err('DENIED', 'location permission denied');
172
+ geoWatchListener = makeListener((loc) => bridge.emit('geo.position', toPosition(loc)));
173
+ Utils.dispatchToMainThread(() => {
174
+ locationManager().requestLocationUpdates(android.location.LocationManager.GPS_PROVIDER, 1000, 0,
175
+ geoWatchListener, android.os.Looper.getMainLooper());
176
+ });
177
+ });
178
+
179
+ bridge.register('geo.watch.stop', () => {
180
+ if (geoWatchListener) locationManager().removeUpdates(geoWatchListener);
181
+ geoWatchListener = null;
182
+ });
183
+
184
+ // ── photos (system photo picker / ACTION_GET_CONTENT) ──────────────
185
+ bridge.register('photos.pick', async ({ dataUrl, maxSize }: any = {}) => {
186
+ const intent = android.os.Build.VERSION.SDK_INT >= 33
187
+ ? new android.content.Intent(android.provider.MediaStore.ACTION_PICK_IMAGES)
188
+ : new android.content.Intent(android.content.Intent.ACTION_GET_CONTENT).setType('image/*');
189
+ const { resultCode, intent: result } = await startActivityForResult(intent);
190
+ if (resultCode !== android.app.Activity.RESULT_OK || !result?.getData()) return { picked: false };
191
+
192
+ const uri = result.getData();
193
+ try {
194
+ const opts = new android.graphics.BitmapFactory.Options();
195
+ opts.inJustDecodeBounds = true;
196
+ const stream = context().getContentResolver().openInputStream(uri);
197
+ android.graphics.BitmapFactory.decodeStream(stream, null, opts);
198
+ stream.close();
199
+ const out: any = { picked: true, width: opts.outWidth, height: opts.outHeight };
200
+ if (dataUrl) out.dataUrl = uriToDataUrl(uri, maxSize ?? 1024);
201
+ return out;
202
+ } catch (e) {
203
+ console.warn('AppWrap: photos.pick size probe failed', e);
204
+ return { picked: true };
205
+ }
206
+ });
207
+
208
+ // ── screen (insets, brightness, keep-awake) ────────────────────────
209
+ bridge.register('ui.safeArea', () => {
210
+ const insets = { top: 0, bottom: 0, left: 0, right: 0 };
211
+ try {
212
+ const cutout = activity()?.getWindow()?.getDecorView()?.getRootWindowInsets()?.getDisplayCutout();
213
+ if (cutout) {
214
+ const dip = (px: number) => Utils.layout.toDeviceIndependentPixels(px);
215
+ insets.top = dip(cutout.getSafeInsetTop());
216
+ insets.bottom = dip(cutout.getSafeInsetBottom());
217
+ insets.left = dip(cutout.getSafeInsetLeft());
218
+ insets.right = dip(cutout.getSafeInsetRight());
219
+ }
220
+ } catch (e) {
221
+ console.warn('AppWrap: ui.safeArea failed', e);
222
+ }
223
+ return insets;
224
+ });
225
+
226
+ bridge.register('ui.brightness.get', () => {
227
+ const level = activity()?.getWindow()?.getAttributes()?.screenBrightness ?? -1;
228
+ return level < 0 ? 0.5 : level; // -1 = follow system
229
+ });
230
+
231
+ bridge.register('ui.brightness.set', ({ level }: { level: number }) => {
232
+ Utils.dispatchToMainThread(() => {
233
+ const window = activity()?.getWindow();
234
+ if (!window) return;
235
+ const attrs = window.getAttributes();
236
+ attrs.screenBrightness = Math.max(0, Math.min(1, level));
237
+ window.setAttributes(attrs);
238
+ });
239
+ });
240
+
241
+ bridge.register('ui.keepAwake', ({ on }: { on: boolean }) => {
242
+ Utils.dispatchToMainThread(() => {
243
+ const window = activity()?.getWindow();
244
+ if (!window) return;
245
+ const flag = android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON;
246
+ on ? window.addFlags(flag) : window.clearFlags(flag);
247
+ });
248
+ });
249
+
250
+ // ── motion (SensorManager: accelerometer + gyroscope) ──────────────
251
+ const sensorManager = () => context().getSystemService(android.content.Context.SENSOR_SERVICE);
252
+ let motionListener: any = null;
253
+ const lastMotion = { ax: 0, ay: 0, az: 0, rx: 0, ry: 0, rz: 0 };
254
+
255
+ bridge.register('motion.start', () => {
256
+ if (motionListener) return; // already streaming
257
+ const sm = sensorManager();
258
+ const accel = sm.getDefaultSensor(android.hardware.Sensor.TYPE_ACCELEROMETER);
259
+ if (!accel) throw err('UNSUPPORTED', 'No accelerometer on this device');
260
+ const gyro = sm.getDefaultSensor(android.hardware.Sensor.TYPE_GYROSCOPE);
261
+ let last = 0;
262
+ motionListener = new android.hardware.SensorEventListener({
263
+ onAccuracyChanged() {},
264
+ onSensorChanged(event: any) {
265
+ const v = event.values;
266
+ if (event.sensor.getType() === android.hardware.Sensor.TYPE_ACCELEROMETER) {
267
+ // Android accelerometer already reports m/s² incl. gravity — kit contract matches.
268
+ lastMotion.ax = v[0]; lastMotion.ay = v[1]; lastMotion.az = v[2];
269
+ } else {
270
+ lastMotion.rx = v[0]; lastMotion.ry = v[1]; lastMotion.rz = v[2];
271
+ }
272
+ const now = java.lang.System.currentTimeMillis();
273
+ if (now - last < 100) return; // ~10Hz, matches iOS
274
+ last = now;
275
+ bridge.emit('motion.data', { ...lastMotion });
276
+ },
277
+ });
278
+ const RATE = android.hardware.SensorManager.SENSOR_DELAY_GAME;
279
+ sm.registerListener(motionListener, accel, RATE);
280
+ if (gyro) sm.registerListener(motionListener, gyro, RATE);
281
+ });
282
+
283
+ bridge.register('motion.stop', () => {
284
+ if (motionListener) sensorManager().unregisterListener(motionListener);
285
+ motionListener = null;
286
+ });
287
+
288
+ // ── contacts (ACTION_PICK + ContactsContract query) ────────────────
289
+ bridge.register('contacts.pick', async () => {
290
+ if (!(await requestPermissions(['android.permission.READ_CONTACTS']))) {
291
+ throw err('DENIED', 'contacts permission denied');
292
+ }
293
+ const CC = android.provider.ContactsContract;
294
+ const intent = new android.content.Intent(android.content.Intent.ACTION_PICK, CC.Contacts.CONTENT_URI);
295
+ const { resultCode, intent: result } = await startActivityForResult(intent);
296
+ if (resultCode !== android.app.Activity.RESULT_OK || !result?.getData()) return { picked: false };
297
+
298
+ const cr = context().getContentResolver();
299
+ const column = (uri: any, col: string, sel: string | null, args: string[] | null): string[] => {
300
+ const out: string[] = [];
301
+ const cursor = cr.query(uri, [col], sel, args, null);
302
+ if (cursor) {
303
+ const idx = cursor.getColumnIndex(col);
304
+ while (cursor.moveToNext()) out.push(String(cursor.getString(idx)));
305
+ cursor.close();
306
+ }
307
+ return out;
308
+ };
309
+
310
+ const contactId = column(result.getData(), CC.Contacts._ID, null, null)[0];
311
+ const name = column(result.getData(), CC.Contacts.DISPLAY_NAME, null, null)[0] ?? '';
312
+ if (!contactId) return { picked: true, name, phones: [], emails: [] };
313
+ return {
314
+ picked: true,
315
+ name,
316
+ phones: column(CC.CommonDataKinds.Phone.CONTENT_URI, CC.CommonDataKinds.Phone.NUMBER,
317
+ CC.CommonDataKinds.Phone.CONTACT_ID + ' = ?', [contactId]),
318
+ emails: column(CC.CommonDataKinds.Email.CONTENT_URI, CC.CommonDataKinds.Email.ADDRESS,
319
+ CC.CommonDataKinds.Email.CONTACT_ID + ' = ?', [contactId]),
320
+ };
321
+ });
322
+
323
+ // ── calendar (CalendarContract direct insert) ──────────────────────
324
+ bridge.register('calendar.createEvent', async ({ title, start, durationMin, notes }: any) => {
325
+ if (!(await requestPermissions(['android.permission.WRITE_CALENDAR', 'android.permission.READ_CALENDAR']))) {
326
+ throw err('DENIED', 'calendar permission denied');
327
+ }
328
+ const CalC = android.provider.CalendarContract;
329
+ const cr = context().getContentResolver();
330
+ const calCursor = cr.query(CalC.Calendars.CONTENT_URI, [CalC.Calendars._ID], null, null, null);
331
+ let calId = -1;
332
+ if (calCursor && calCursor.moveToFirst()) calId = calCursor.getLong(0);
333
+ calCursor?.close();
334
+ if (calId < 0) throw err('UNSUPPORTED', 'No calendar account on this device');
335
+
336
+ const startMs = start ? Date.parse(start) : Date.now() + 3600_000;
337
+ const endMs = startMs + (durationMin ?? 60) * 60_000;
338
+ const values = new android.content.ContentValues();
339
+ values.put('dtstart', java.lang.Long.valueOf(String(startMs)));
340
+ values.put('dtend', java.lang.Long.valueOf(String(endMs)));
341
+ values.put('title', String(title ?? 'Event'));
342
+ if (notes) values.put('description', String(notes));
343
+ values.put('calendar_id', java.lang.Long.valueOf(String(calId)));
344
+ values.put('eventTimezone', java.util.TimeZone.getDefault().getID());
345
+ const uri = cr.insert(CalC.Events.CONTENT_URI, values);
346
+ return { id: uri ? String(android.content.ContentUris.parseId(uri)) : '' };
347
+ });
348
+
349
+ // ── camera capture (MediaStore image-capture, thumbnail result) ────
350
+ bridge.register('camera.capture', async ({ dataUrl }: any = {}) => {
351
+ if (!(await requestPermissions(['android.permission.CAMERA']))) {
352
+ throw err('DENIED', 'camera permission denied');
353
+ }
354
+ const intent = new android.content.Intent(android.provider.MediaStore.ACTION_IMAGE_CAPTURE);
355
+ if (!intent.resolveActivity(context().getPackageManager())) {
356
+ throw err('UNSUPPORTED', 'No camera app on this device');
357
+ }
358
+ const { resultCode, intent: result } = await startActivityForResult(intent);
359
+ if (resultCode !== android.app.Activity.RESULT_OK) return { picked: false };
360
+ const bmp = result?.getExtras()?.get('data'); // thumbnail Bitmap
361
+ if (!bmp) return { picked: true };
362
+ const out: any = { picked: true, width: bmp.getWidth(), height: bmp.getHeight() };
363
+ if (dataUrl) out.dataUrl = bitmapToDataUrl(bmp);
364
+ return out;
365
+ });
366
+
367
+ // ── file share (FileProvider content:// URIs + ACTION_SEND[_MULTIPLE]) ──
368
+ bridge.register('share.files', ({ files, text, title }: { files: Array<{ name: string; mimeType: string; base64: string }>; text?: string; title?: string }) => {
369
+ const ctx = context();
370
+ const authority = ctx.getPackageName() + '.fileprovider';
371
+ const dir = new java.io.File(ctx.getCacheDir(), 'shared');
372
+ dir.mkdirs();
373
+ const uris = new java.util.ArrayList();
374
+ const mimes = new java.util.HashSet();
375
+ (files ?? []).forEach((f: any, i: number) => {
376
+ const bytes = android.util.Base64.decode(f.base64, android.util.Base64.DEFAULT);
377
+ const out = new java.io.File(dir, i + '-' + (f.name || 'file')); // index-prefix: avoid same-name collisions
378
+ const fos = new java.io.FileOutputStream(out);
379
+ fos.write(bytes);
380
+ fos.close();
381
+ uris.add(androidx.core.content.FileProvider.getUriForFile(ctx, authority, out));
382
+ if (f.mimeType) mimes.add(f.mimeType);
383
+ });
384
+ if (uris.size() === 0) return;
385
+ const single = uris.size() === 1;
386
+ const I = android.content.Intent;
387
+ const intent = new I(single ? I.ACTION_SEND : I.ACTION_SEND_MULTIPLE);
388
+ intent.setType(mimes.size() === 1 ? mimes.iterator().next() : 'application/octet-stream');
389
+ if (single) intent.putExtra(I.EXTRA_STREAM, uris.get(0));
390
+ else intent.putParcelableArrayListExtra(I.EXTRA_STREAM, uris);
391
+ if (text) intent.putExtra(I.EXTRA_TEXT, String(text));
392
+ if (title) intent.putExtra(I.EXTRA_SUBJECT, String(title));
393
+ intent.addFlags(I.FLAG_GRANT_READ_URI_PERMISSION);
394
+ const chooser = I.createChooser(intent, title ?? 'Share');
395
+ chooser.addFlags(I.FLAG_ACTIVITY_NEW_TASK);
396
+ ctx.startActivity(chooser);
397
+ });
398
+
399
+ // ── screen orientation (Activity.setRequestedOrientation) ──────────
400
+ const SO = android.content.pm.ActivityInfo;
401
+ const requestedOrientation = (o: string): number => {
402
+ switch (o) {
403
+ case 'portrait': return SO.SCREEN_ORIENTATION_PORTRAIT;
404
+ case 'portrait-upside-down': return SO.SCREEN_ORIENTATION_REVERSE_PORTRAIT;
405
+ case 'landscape': return SO.SCREEN_ORIENTATION_LANDSCAPE;
406
+ case 'landscape-left': return SO.SCREEN_ORIENTATION_LANDSCAPE;
407
+ case 'landscape-right': return SO.SCREEN_ORIENTATION_REVERSE_LANDSCAPE;
408
+ default: return SO.SCREEN_ORIENTATION_UNSPECIFIED;
409
+ }
410
+ };
411
+ bridge.register('screen.orientation.lock', ({ orientation }: { orientation: string }) => {
412
+ activity()?.setRequestedOrientation(requestedOrientation(String(orientation)));
413
+ });
414
+ bridge.register('screen.orientation.unlock', () => {
415
+ activity()?.setRequestedOrientation(SO.SCREEN_ORIENTATION_UNSPECIFIED);
416
+ });
417
+ }
@@ -0,0 +1,197 @@
1
+ import { Utils, isIOS } from '@nativescript/core';
2
+ import { bridge } from './bridge';
3
+
4
+ /**
5
+ * In-app purchases — iOS StoreKit 1 (SKPaymentQueue). StoreKit 2 (Product/Transaction)
6
+ * is Swift-only and unreachable via the ObjC bridge, so we use the delegate-based
7
+ * SK1 API every NativeScript/Cordova IAP plugin uses. The web layer's validator turns
8
+ * the returned app receipt into entitlements (server-of-record). Android billing is a
9
+ * separate handler (Play Billing) and is currently unimplemented (capability 'none').
10
+ */
11
+
12
+ declare const SKPaymentQueue: any;
13
+ declare const SKPayment: any;
14
+ declare const SKProductsRequest: any;
15
+ declare const SKProductsRequestDelegate: any;
16
+ declare const SKPaymentTransactionObserver: any;
17
+ declare const NSSet: any;
18
+ declare const NSBundle: any;
19
+ declare const NSData: any;
20
+ declare const NSNumberFormatter: any;
21
+
22
+ const PERIOD_UNIT = ['D', 'W', 'M', 'Y']; // SKProductPeriodUnit: Day/Week/Month/Year
23
+ const STATE = { Purchasing: 0, Purchased: 1, Failed: 2, Restored: 3, Deferred: 4 };
24
+ const SK_ERR_CANCELLED = 2; // SKErrorPaymentCancelled
25
+
26
+ function iosOnly(): Error {
27
+ return Object.assign(new Error('iOS only — Android billing not wired'), { code: 'UNSUPPORTED' });
28
+ }
29
+ function err(code: string, message: string): Error {
30
+ return Object.assign(new Error(message), { code });
31
+ }
32
+
33
+ /** Base64 of the bundle's App Store receipt — the proof a validator sends to Apple. */
34
+ function appReceipt(): string {
35
+ const url = NSBundle.mainBundle.appStoreReceiptURL;
36
+ if (!url) return '';
37
+ const data = NSData.dataWithContentsOfURL(url);
38
+ return data ? data.base64EncodedStringWithOptions(0) : '';
39
+ }
40
+
41
+ function mapProduct(p: any) {
42
+ const f = NSNumberFormatter.new();
43
+ f.numberStyle = 2; // CurrencyStyle
44
+ f.locale = p.priceLocale;
45
+ const sp = p.subscriptionPeriod;
46
+ return {
47
+ id: String(p.productIdentifier),
48
+ title: String(p.localizedTitle ?? ''),
49
+ description: String(p.localizedDescription ?? ''),
50
+ price: p.price ? p.price.doubleValue : 0,
51
+ displayPrice: String(f.stringFromNumber(p.price) ?? ''),
52
+ currency: String(p.priceLocale?.currencyCode ?? ''),
53
+ type: sp ? 'autoRenewable' : 'unknown',
54
+ subscriptionPeriod: sp ? `P${sp.numberOfUnits}${PERIOD_UNIT[sp.unit] ?? 'D'}` : undefined,
55
+ };
56
+ }
57
+
58
+ function receiptFor(productId: string, transactionId?: string) {
59
+ return { platform: 'ios', productId, transactionId, appReceipt: appReceipt(), raw: {} };
60
+ }
61
+
62
+ // In-flight resolvers, keyed so the single queue observer can route callbacks.
63
+ const purchaseWaiters = new Map<string, { resolve: (r: any) => void; reject: (e: any) => void }>();
64
+ let restoreWaiter: { receipts: any[]; resolve: (r: any[]) => void; reject: (e: any) => void } | null = null;
65
+ const productRequests = new Set<any>(); // keep strong refs to in-flight requests + delegates
66
+ let observer: any = null;
67
+
68
+ function buildObserver() {
69
+ const ObserverClass = (NSObject as any).extend(
70
+ {
71
+ paymentQueueUpdatedTransactions(queue: any, transactions: any): void {
72
+ const n = transactions.count;
73
+ for (let i = 0; i < n; i++) {
74
+ const t = transactions.objectAtIndex(i);
75
+ const pid = String(t.payment.productIdentifier);
76
+ switch (t.transactionState) {
77
+ case STATE.Purchased: {
78
+ queue.finishTransaction(t);
79
+ const r = receiptFor(pid, String(t.transactionIdentifier ?? ''));
80
+ bridge.emit('billing.transaction', r);
81
+ purchaseWaiters.get(pid)?.resolve(r);
82
+ purchaseWaiters.delete(pid);
83
+ break;
84
+ }
85
+ case STATE.Restored: {
86
+ queue.finishTransaction(t);
87
+ const r = receiptFor(pid, String(t.transactionIdentifier ?? ''));
88
+ if (restoreWaiter) restoreWaiter.receipts.push(r);
89
+ bridge.emit('billing.transaction', r);
90
+ break;
91
+ }
92
+ case STATE.Failed: {
93
+ queue.finishTransaction(t);
94
+ const cancelled = t.error && t.error.code === SK_ERR_CANCELLED;
95
+ const e = err(cancelled ? 'DENIED' : 'NATIVE_ERROR', t.error?.localizedDescription ?? 'Purchase failed');
96
+ purchaseWaiters.get(pid)?.reject(e);
97
+ purchaseWaiters.delete(pid);
98
+ break;
99
+ }
100
+ // Purchasing / Deferred: nothing to do.
101
+ }
102
+ }
103
+ },
104
+ paymentQueueRestoreCompletedTransactionsFinished(): void {
105
+ restoreWaiter?.resolve(restoreWaiter.receipts);
106
+ restoreWaiter = null;
107
+ },
108
+ paymentQueueRestoreCompletedTransactionsFailedWithError(_q: any, error: any): void {
109
+ restoreWaiter?.reject(err('NATIVE_ERROR', error?.localizedDescription ?? 'Restore failed'));
110
+ restoreWaiter = null;
111
+ },
112
+ },
113
+ { protocols: [SKPaymentTransactionObserver] }
114
+ );
115
+ return ObserverClass.new();
116
+ }
117
+
118
+ /** One SKProductsRequest → resolves the raw SKProduct objects (used by both products + purchase). */
119
+ function requestSKProducts(ids: string[]): Promise<any[]> {
120
+ return new Promise((resolve, reject) => {
121
+ const set = NSSet.setWithArray(ids as any);
122
+ const request = SKProductsRequest.alloc().initWithProductIdentifiers(set);
123
+ const DelegateClass = (NSObject as any).extend(
124
+ {
125
+ productsRequestDidReceiveResponse(_req: any, response: any): void {
126
+ const out: any[] = [];
127
+ const products = response.products;
128
+ for (let i = 0; i < products.count; i++) out.push(products.objectAtIndex(i));
129
+ productRequests.delete(holder);
130
+ resolve(out);
131
+ },
132
+ requestDidFailWithError(_req: any, error: any): void {
133
+ productRequests.delete(holder);
134
+ reject(err('NATIVE_ERROR', error?.localizedDescription ?? 'Products request failed'));
135
+ },
136
+ },
137
+ { protocols: [SKProductsRequestDelegate] }
138
+ );
139
+ const delegate = DelegateClass.new();
140
+ const holder = { request, delegate };
141
+ productRequests.add(holder); // strong ref until the callback fires
142
+ request.delegate = delegate;
143
+ request.start();
144
+ });
145
+ }
146
+
147
+ export function registerBillingHandlers(): void {
148
+ if (!isIOS) return; // Android billing is a separate, not-yet-wired handler
149
+
150
+ observer = buildObserver();
151
+ SKPaymentQueue.defaultQueue().addTransactionObserver(observer);
152
+
153
+ bridge.register('billing.products', async ({ ids = [] }: { ids: string[] }) => {
154
+ if (!ids.length) return [];
155
+ return (await requestSKProducts(ids)).map(mapProduct);
156
+ });
157
+
158
+ bridge.register('billing.purchase', async ({ productId }: { productId: string }) => {
159
+ if (!SKPaymentQueue.canMakePayments()) throw err('DENIED', 'Purchases disabled on this device');
160
+ const sk = (await requestSKProducts([productId]))[0];
161
+ if (!sk) throw err('NATIVE_ERROR', `Unknown product: ${productId}`);
162
+ return new Promise((resolve, reject) => {
163
+ purchaseWaiters.set(productId, { resolve, reject });
164
+ SKPaymentQueue.defaultQueue().addPayment(SKPayment.paymentWithProduct(sk));
165
+ });
166
+ });
167
+
168
+ bridge.register('billing.restore', () => restoreTransactions());
169
+
170
+ // Client-trusted entitlements: StoreKit 1 has no on-device entitlement list, so we
171
+ // derive non-consumed/active products from a restore. Real apps plug in a server
172
+ // validator (see kit.billing.configure) instead of trusting this.
173
+ bridge.register('billing.entitlements', async () => {
174
+ const receipts = await restoreTransactions();
175
+ const seen = new Set<string>();
176
+ const ents: any[] = [];
177
+ for (const r of receipts) {
178
+ if (seen.has(r.productId)) continue;
179
+ seen.add(r.productId);
180
+ ents.push({ productId: r.productId, active: true });
181
+ }
182
+ return ents;
183
+ });
184
+
185
+ bridge.register('billing.manageSubscriptions', () => {
186
+ // StoreKit 2's AppStore.showManageSubscriptions is Swift-only; deep-link instead.
187
+ Utils.openUrl('itms-apps://apps.apple.com/account/subscriptions');
188
+ });
189
+ }
190
+
191
+ function restoreTransactions(): Promise<any[]> {
192
+ if (restoreWaiter) return Promise.reject(err('NOT_READY', 'A restore is already in progress'));
193
+ return new Promise((resolve, reject) => {
194
+ restoreWaiter = { receipts: [], resolve, reject };
195
+ SKPaymentQueue.defaultQueue().restoreCompletedTransactions();
196
+ });
197
+ }