@nebula-rn/host-apis 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1072 @@
1
+ import {
2
+ Dimensions,
3
+ Image,
4
+ Linking,
5
+ PermissionsAndroid,
6
+ PixelRatio,
7
+ Platform,
8
+ StatusBar,
9
+ } from 'react-native';
10
+ import Clipboard from '@react-native-clipboard/clipboard';
11
+ import Geolocation from '@react-native-community/geolocation';
12
+ import NetInfo from '@react-native-community/netinfo';
13
+ import { CameraRoll } from '@react-native-camera-roll/camera-roll';
14
+ import ScreenshotAware from 'react-native-screenshot-aware';
15
+ import {
16
+ accelerometer,
17
+ barometer,
18
+ gyroscope,
19
+ magnetometer,
20
+ SensorTypes,
21
+ setUpdateIntervalForType,
22
+ } from 'react-native-sensors';
23
+ import { createMMKV } from 'react-native-mmkv';
24
+ import DeviceInfo, { getBrightness } from 'react-native-device-info';
25
+ import { initialWindowMetrics } from 'react-native-safe-area-context';
26
+ import ImageResizer from '@bam.tech/react-native-image-resizer';
27
+ import {
28
+ Asset,
29
+ CameraOptions,
30
+ ImageLibraryOptions,
31
+ ImagePickerResponse,
32
+ launchCamera,
33
+ launchImageLibrary,
34
+ } from 'react-native-image-picker';
35
+ import * as RNFS from '@dr.pogodin/react-native-fs';
36
+ import {
37
+ NebulaAPI,
38
+ createHostApiFeature,
39
+ createHostApiFailure,
40
+ createHostApiSuccess,
41
+ } from '@nebula-rn/sdk';
42
+ import type { NebulaHostApiDescriptionMap } from '@nebula-rn/sdk';
43
+ import {
44
+ createHostBridgeId,
45
+ createSubscriptionStartResult,
46
+ emitSubscriptionEvent,
47
+ } from './hostApiBridge';
48
+ import { downloadFileHostApi, uploadFileHostApi } from './taskHosts';
49
+
50
+ const hostStorage = createMMKV({
51
+ id: 'nebula.host.api.storage',
52
+ });
53
+
54
+ const locationSubscriptions = new Map<string, number>();
55
+ const networkSubscriptions = new Map<string, () => void>();
56
+ const screenshotSubscriptions = new Map<string, { remove: () => void }>();
57
+ const sensorSubscriptions = new Map<string, { unsubscribe: () => void }>();
58
+
59
+ const HOST_API_DESCRIPTIONS: NebulaHostApiDescriptionMap = {
60
+ getAppBaseInfo: {
61
+ summary:
62
+ 'Return basic host app information for the current miniapp runtime.',
63
+ tags: ['device', 'app'],
64
+ },
65
+ getClipboardData: {
66
+ summary: 'Read plain text from the system clipboard.',
67
+ tags: ['clipboard'],
68
+ },
69
+ setClipboardData: {
70
+ summary: 'Write plain text to the system clipboard.',
71
+ tags: ['clipboard'],
72
+ },
73
+ getLocation: {
74
+ summary: 'Get the current device location once.',
75
+ description: 'Requests runtime location permission on Android when needed.',
76
+ tags: ['location'],
77
+ },
78
+ getScreenBrightness: {
79
+ summary: 'Read the current screen brightness reported by the device.',
80
+ tags: ['device'],
81
+ },
82
+ getSystemInfo: {
83
+ summary:
84
+ 'Return device, screen, and safe-area information for layout and diagnostics.',
85
+ tags: ['device', 'layout'],
86
+ },
87
+ makePhoneCall: {
88
+ summary: 'Open the system dialer for a phone number.',
89
+ tags: ['device', 'communication'],
90
+ },
91
+ getImageInfo: {
92
+ summary: 'Read width and height information for an image source.',
93
+ tags: ['media', 'image'],
94
+ },
95
+ compressImage: {
96
+ summary: 'Resize and compress an image on the host side.',
97
+ tags: ['media', 'image'],
98
+ },
99
+ getFileInfo: {
100
+ summary: 'Read size and digest information for a file path.',
101
+ tags: ['file'],
102
+ },
103
+ chooseMedia: {
104
+ summary:
105
+ 'Open the system image or video picker and return selected assets.',
106
+ tags: ['media', 'picker'],
107
+ },
108
+ saveMedia: {
109
+ summary: 'Save a photo or video into the system media library.',
110
+ tags: ['media'],
111
+ },
112
+ 'storage.setItem': {
113
+ summary: 'Persist a string value in host-backed key-value storage.',
114
+ tags: ['storage'],
115
+ },
116
+ 'storage.getItem': {
117
+ summary: 'Read a string value from host-backed key-value storage.',
118
+ tags: ['storage'],
119
+ },
120
+ 'storage.removeItem': {
121
+ summary: 'Remove a stored key from host-backed key-value storage.',
122
+ tags: ['storage'],
123
+ },
124
+ 'storage.clearItems': {
125
+ summary:
126
+ 'Clear all host-backed key-value storage entries for the current host store.',
127
+ tags: ['storage'],
128
+ },
129
+ 'storage.getKeys': {
130
+ summary: 'List all keys currently stored in host-backed key-value storage.',
131
+ tags: ['storage'],
132
+ },
133
+ 'storage.getCurrentSize': {
134
+ summary:
135
+ 'Return the approximate size of host-backed key-value storage in KB.',
136
+ tags: ['storage'],
137
+ },
138
+ getNetworkType: {
139
+ summary: 'Read the current network connection type and connectivity state.',
140
+ tags: ['network'],
141
+ },
142
+ getMiniAppUpdateInfo: {
143
+ summary:
144
+ 'Return the currently installed version and whether a newer remote version is available.',
145
+ tags: ['miniapp', 'update'],
146
+ },
147
+ applyMiniAppUpdate: {
148
+ summary:
149
+ 'Install the latest available remote bundle for the current miniapp when an update exists.',
150
+ tags: ['miniapp', 'update'],
151
+ },
152
+ 'locationChange.subscribe': {
153
+ summary: 'Start location change subscription events from the host.',
154
+ tags: ['location', 'subscription'],
155
+ },
156
+ 'locationChange.unsubscribe': {
157
+ summary: 'Stop a location change subscription by subscription id.',
158
+ tags: ['location', 'subscription'],
159
+ },
160
+ 'networkStatusChange.subscribe': {
161
+ summary: 'Start network status change subscription events from the host.',
162
+ tags: ['network', 'subscription'],
163
+ },
164
+ 'networkStatusChange.unsubscribe': {
165
+ summary: 'Stop a network status change subscription by subscription id.',
166
+ tags: ['network', 'subscription'],
167
+ },
168
+ 'userCaptureScreen.subscribe': {
169
+ summary: 'Start user screenshot capture subscription events from the host.',
170
+ tags: ['device', 'subscription'],
171
+ },
172
+ 'userCaptureScreen.unsubscribe': {
173
+ summary: 'Stop a user screenshot capture subscription by subscription id.',
174
+ tags: ['device', 'subscription'],
175
+ },
176
+ 'accelerometerChange.subscribe': {
177
+ summary: 'Start accelerometer sensor subscription events from the host.',
178
+ tags: ['sensor', 'subscription'],
179
+ },
180
+ 'accelerometerChange.unsubscribe': {
181
+ summary: 'Stop an accelerometer subscription by subscription id.',
182
+ tags: ['sensor', 'subscription'],
183
+ },
184
+ 'gyroscopeChange.subscribe': {
185
+ summary: 'Start gyroscope sensor subscription events from the host.',
186
+ tags: ['sensor', 'subscription'],
187
+ },
188
+ 'gyroscopeChange.unsubscribe': {
189
+ summary: 'Stop a gyroscope subscription by subscription id.',
190
+ tags: ['sensor', 'subscription'],
191
+ },
192
+ 'magnetometerChange.subscribe': {
193
+ summary: 'Start magnetometer sensor subscription events from the host.',
194
+ tags: ['sensor', 'subscription'],
195
+ },
196
+ 'magnetometerChange.unsubscribe': {
197
+ summary: 'Stop a magnetometer subscription by subscription id.',
198
+ tags: ['sensor', 'subscription'],
199
+ },
200
+ 'barometerChange.subscribe': {
201
+ summary: 'Start barometer sensor subscription events from the host.',
202
+ tags: ['sensor', 'subscription'],
203
+ },
204
+ 'barometerChange.unsubscribe': {
205
+ summary: 'Stop a barometer subscription by subscription id.',
206
+ tags: ['sensor', 'subscription'],
207
+ },
208
+ 'fileSystem.access': {
209
+ summary: 'Check whether a file or directory path exists and is accessible.',
210
+ tags: ['file'],
211
+ },
212
+ 'fileSystem.appendFile': {
213
+ summary: 'Append text data to a file path.',
214
+ tags: ['file'],
215
+ },
216
+ 'fileSystem.saveFile': {
217
+ summary:
218
+ 'Copy a temp file into the miniapp sandbox and return the saved path.',
219
+ tags: ['file'],
220
+ },
221
+ 'fileSystem.copyFile': {
222
+ summary: 'Copy a file from one path to another.',
223
+ tags: ['file'],
224
+ },
225
+ 'fileSystem.mkdir': {
226
+ summary: 'Create a directory path recursively.',
227
+ tags: ['file'],
228
+ },
229
+ 'fileSystem.readFile': {
230
+ summary: 'Read a file as text with optional encoding.',
231
+ tags: ['file'],
232
+ },
233
+ 'fileSystem.readdir': {
234
+ summary: 'List child entries in a directory.',
235
+ tags: ['file'],
236
+ },
237
+ 'fileSystem.rename': {
238
+ summary: 'Rename or move a file or directory.',
239
+ tags: ['file'],
240
+ },
241
+ 'fileSystem.rmdir': {
242
+ summary: 'Remove a directory path recursively.',
243
+ tags: ['file'],
244
+ },
245
+ 'fileSystem.unlink': {
246
+ summary: 'Delete a file path.',
247
+ tags: ['file'],
248
+ },
249
+ 'fileSystem.writeFile': {
250
+ summary: 'Write text data to a file path.',
251
+ tags: ['file'],
252
+ },
253
+ 'fileSystem.getFileInfo': {
254
+ summary: 'Read file stat information for a sandbox path.',
255
+ tags: ['file'],
256
+ },
257
+ removeFile: {
258
+ summary:
259
+ 'Remove a file from the miniapp sandbox and return whether it was deleted.',
260
+ tags: ['file'],
261
+ },
262
+ };
263
+
264
+ async function ensureLocationPermission(): Promise<boolean> {
265
+ if (Platform.OS !== 'android') {
266
+ return true;
267
+ }
268
+
269
+ const finePermission = PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION;
270
+ const coarsePermission =
271
+ PermissionsAndroid.PERMISSIONS.ACCESS_COARSE_LOCATION;
272
+
273
+ const fineGranted = await PermissionsAndroid.check(finePermission);
274
+ const coarseGranted = await PermissionsAndroid.check(coarsePermission);
275
+ if (fineGranted || coarseGranted) {
276
+ return true;
277
+ }
278
+
279
+ const granted = await PermissionsAndroid.request(finePermission);
280
+ if (granted === PermissionsAndroid.RESULTS.GRANTED) {
281
+ return true;
282
+ }
283
+
284
+ const coarseGrantedAfterRequest =
285
+ await PermissionsAndroid.request(coarsePermission);
286
+ return coarseGrantedAfterRequest === PermissionsAndroid.RESULTS.GRANTED;
287
+ }
288
+
289
+ type ChooseMediaOption = {
290
+ count?: number;
291
+ mediaType?: ('image' | 'video' | 'mix')[];
292
+ sourceType?: ('album' | 'camera')[];
293
+ maxDuration?: number;
294
+ sizeType?: ('original' | 'compressed')[];
295
+ camera?: 'back' | 'front';
296
+ };
297
+
298
+ type SaveMediaPayload = {
299
+ url: string;
300
+ type: 'photo' | 'video';
301
+ album?: string;
302
+ };
303
+
304
+ function resolveMiniAppPath(path: unknown, appId?: string): string {
305
+ const rawPath = String(path ?? '');
306
+
307
+ if (!rawPath) {
308
+ return '';
309
+ }
310
+
311
+ const normalizedPath = rawPath.startsWith('file://')
312
+ ? rawPath.slice('file://'.length)
313
+ : rawPath;
314
+ const miniAppPrefix = `/Documents/MiniApps/${appId ?? ''}`;
315
+
316
+ if (appId && normalizedPath.startsWith(miniAppPrefix)) {
317
+ return `${RNFS.DocumentDirectoryPath}/MiniApps/${appId}${normalizedPath.slice(
318
+ miniAppPrefix.length,
319
+ )}`;
320
+ }
321
+
322
+ if (normalizedPath.startsWith('/Documents/')) {
323
+ return `${RNFS.DocumentDirectoryPath}${normalizedPath.slice(
324
+ '/Documents'.length,
325
+ )}`;
326
+ }
327
+
328
+ return normalizedPath;
329
+ }
330
+
331
+ export const saveMediaHost = async (payload: SaveMediaPayload) => {
332
+ return CameraRoll.saveAsset(payload.url, {
333
+ type: payload.type,
334
+ album: payload.album,
335
+ });
336
+ };
337
+
338
+ function getSafeArea() {
339
+ const screen = Dimensions.get('screen');
340
+ const screenWidth = screen.width;
341
+ const screenHeight = screen.height;
342
+ const insets = initialWindowMetrics?.insets;
343
+ let top = insets?.top ?? 0;
344
+ const bottom = insets?.bottom ?? 0;
345
+
346
+ if (Platform.OS === 'android') {
347
+ top = StatusBar.currentHeight ?? top;
348
+ }
349
+
350
+ const w = Math.min(screenWidth, screenHeight);
351
+ const h = Math.max(screenWidth, screenHeight);
352
+
353
+ return {
354
+ left: 0,
355
+ right: w,
356
+ top,
357
+ bottom: h - bottom,
358
+ height: Math.max(0, h - bottom - top),
359
+ width: w,
360
+ };
361
+ }
362
+
363
+ function getStorageKeys(): string[] {
364
+ return hostStorage.getAllKeys();
365
+ }
366
+
367
+ function getStorageCurrentSize(): number {
368
+ const keys = getStorageKeys();
369
+ const size = keys.reduce((total, key) => {
370
+ const value = hostStorage.getString(key);
371
+ return total + (value?.length ?? 0);
372
+ }, 0);
373
+
374
+ return Number((size / 1024).toFixed(2));
375
+ }
376
+
377
+ function chooseMediaHost(options: ChooseMediaOption = {}) {
378
+ return new Promise<{
379
+ tempFiles: Array<{
380
+ tempFilePath: string;
381
+ size: number;
382
+ duration?: number;
383
+ height?: number;
384
+ width?: number;
385
+ thumbTempFilePath?: string;
386
+ fileType: 'image' | 'video';
387
+ }>;
388
+ type: 'image' | 'video' | 'mix';
389
+ }>((resolve, reject) => {
390
+ const {
391
+ count = 9,
392
+ mediaType = ['image', 'video'],
393
+ sourceType = ['album', 'camera'],
394
+ maxDuration = 10,
395
+ sizeType = ['original', 'compressed'],
396
+ camera = 'back',
397
+ } = options;
398
+
399
+ const isCompressed = sizeType.includes('compressed');
400
+ let pickerMediaType: 'photo' | 'video' | 'mixed' = 'mixed';
401
+
402
+ if (mediaType.length === 1) {
403
+ if (mediaType[0] === 'image') {
404
+ pickerMediaType = 'photo';
405
+ } else if (mediaType[0] === 'video') {
406
+ pickerMediaType = 'video';
407
+ }
408
+ }
409
+
410
+ const pickerOptions: CameraOptions & ImageLibraryOptions = {
411
+ selectionLimit: count,
412
+ durationLimit: maxDuration,
413
+ cameraType: camera,
414
+ quality: isCompressed ? 0.8 : 1,
415
+ mediaType: pickerMediaType,
416
+ includeExtra: true,
417
+ assetRepresentationMode: 'auto',
418
+ };
419
+
420
+ const onPickerResponse = (response: ImagePickerResponse) => {
421
+ if (response.didCancel) {
422
+ reject(new Error('chooseMedia:fail cancel'));
423
+ return;
424
+ }
425
+
426
+ if (response.errorCode) {
427
+ reject(
428
+ new Error(
429
+ response.errorMessage || `chooseMedia:fail ${response.errorCode}`,
430
+ ),
431
+ );
432
+ return;
433
+ }
434
+
435
+ const tempFiles: Array<{
436
+ tempFilePath: string;
437
+ size: number;
438
+ duration?: number;
439
+ height?: number;
440
+ width?: number;
441
+ thumbTempFilePath?: string;
442
+ fileType: 'image' | 'video';
443
+ }> = (response.assets || []).map((asset: Asset) => ({
444
+ tempFilePath: asset.uri || '',
445
+ size: asset.fileSize || 0,
446
+ duration: asset.duration,
447
+ height: asset.height,
448
+ width: asset.width,
449
+ thumbTempFilePath: undefined,
450
+ fileType: asset.type?.includes('video') ? 'video' : 'image',
451
+ }));
452
+
453
+ resolve({
454
+ tempFiles,
455
+ type: mediaType.includes('mix')
456
+ ? 'mix'
457
+ : (mediaType[0] as 'image' | 'video') || 'image',
458
+ });
459
+ };
460
+
461
+ const isOnlyCamera = sourceType.length === 1 && sourceType[0] === 'camera';
462
+ if (isOnlyCamera) {
463
+ launchCamera(pickerOptions, onPickerResponse);
464
+ return;
465
+ }
466
+
467
+ launchImageLibrary(pickerOptions, onPickerResponse);
468
+ });
469
+ }
470
+
471
+ function buildSystemInfo() {
472
+ const brand = DeviceInfo.getBrand();
473
+ const model = DeviceInfo.getModel();
474
+ const pixelRatio = PixelRatio.get();
475
+ const fontScale = PixelRatio.getFontScale();
476
+ const os = Platform.OS;
477
+ const version = DeviceInfo.getVersion();
478
+ const system = `${os} ${Platform.Version}`;
479
+ const screen = Dimensions.get('screen');
480
+ const window = Dimensions.get('window');
481
+
482
+ return {
483
+ brand,
484
+ model,
485
+ pixelRatio,
486
+ safeArea: getSafeArea(),
487
+ screenWidth: screen.width,
488
+ screenHeight: screen.height,
489
+ windowWidth: window.width,
490
+ windowHeight: window.height,
491
+ statusBarHeight: getSafeArea().top,
492
+ language: null,
493
+ version,
494
+ system,
495
+ platform: os,
496
+ fontSizeSetting: fontScale,
497
+ SDKVersion: null,
498
+ deviceOrientation: screen.height > screen.width ? 'portrait' : 'landscape',
499
+ };
500
+ }
501
+
502
+ function createSensorSubscriptionFeature(
503
+ apiName: string,
504
+ start: (interval?: number) => {
505
+ subscribe: (cb: (data: unknown) => void) => {
506
+ unsubscribe: () => void;
507
+ };
508
+ },
509
+ ) {
510
+ return [
511
+ createHostApiFeature({
512
+ apiName: `${apiName}.subscribe`,
513
+ description: HOST_API_DESCRIPTIONS[`${apiName}.subscribe`],
514
+ async handle(payload, context) {
515
+ const subscriptionId = createHostBridgeId(apiName);
516
+ const interval =
517
+ typeof payload.interval === 'number' ? payload.interval : 100;
518
+ const stream = start(interval);
519
+ const subscription = stream.subscribe(data => {
520
+ emitSubscriptionEvent(
521
+ context.appId,
522
+ apiName,
523
+ subscriptionId,
524
+ data,
525
+ ).catch(() => {});
526
+ });
527
+ sensorSubscriptions.set(subscriptionId, subscription);
528
+ return createSubscriptionStartResult(subscriptionId);
529
+ },
530
+ }),
531
+ createHostApiFeature({
532
+ apiName: `${apiName}.unsubscribe`,
533
+ description: HOST_API_DESCRIPTIONS[`${apiName}.unsubscribe`],
534
+ async handle(payload) {
535
+ const subscriptionId = String(payload.subscriptionId ?? '');
536
+ const subscription = sensorSubscriptions.get(subscriptionId);
537
+ subscription?.unsubscribe();
538
+ sensorSubscriptions.delete(subscriptionId);
539
+ return createHostApiSuccess(null);
540
+ },
541
+ }),
542
+ ];
543
+ }
544
+
545
+ export const coreHostApis = [
546
+ createHostApiFeature({
547
+ apiName: 'getAppBaseInfo',
548
+ description: HOST_API_DESCRIPTIONS.getAppBaseInfo,
549
+ handle: async () =>
550
+ createHostApiSuccess({
551
+ version: DeviceInfo.getVersion(),
552
+ language: '',
553
+ enableDebug: !!__DEV__,
554
+ theme: 'light',
555
+ }),
556
+ }),
557
+ createHostApiFeature({
558
+ apiName: 'getClipboardData',
559
+ description: HOST_API_DESCRIPTIONS.getClipboardData,
560
+ handle: async () => createHostApiSuccess(await Clipboard.getString()),
561
+ }),
562
+ createHostApiFeature({
563
+ apiName: 'setClipboardData',
564
+ description: HOST_API_DESCRIPTIONS.setClipboardData,
565
+ handle: async payload => {
566
+ Clipboard.setString(String(payload.data ?? ''));
567
+ return createHostApiSuccess(null);
568
+ },
569
+ }),
570
+ createHostApiFeature({
571
+ apiName: 'getLocation',
572
+ description: HOST_API_DESCRIPTIONS.getLocation,
573
+ handle: async payload => {
574
+ const hasPermission = await ensureLocationPermission();
575
+ if (!hasPermission) {
576
+ return createHostApiFailure(
577
+ 'PERMISSION_DENIED',
578
+ 'getLocation:fail permission denied',
579
+ );
580
+ }
581
+
582
+ return new Promise(resolve => {
583
+ Geolocation.getCurrentPosition(
584
+ position => {
585
+ resolve(
586
+ createHostApiSuccess({
587
+ latitude: position.coords.latitude,
588
+ longitude: position.coords.longitude,
589
+ speed: position.coords.speed ?? 0,
590
+ accuracy: position.coords.accuracy ?? 0,
591
+ altitude: position.coords.altitude ?? 0,
592
+ verticalAccuracy: position.coords.altitudeAccuracy ?? 0,
593
+ horizontalAccuracy: position.coords.accuracy ?? 0,
594
+ }),
595
+ );
596
+ },
597
+ error => {
598
+ resolve(
599
+ createHostApiFailure(
600
+ 'LOCATION_FAILED',
601
+ error.message || 'getLocation:fail',
602
+ ),
603
+ );
604
+ },
605
+ {
606
+ enableHighAccuracy: !!payload.isHighAccuracy,
607
+ timeout:
608
+ typeof payload.highAccuracyExpireTime === 'number'
609
+ ? payload.highAccuracyExpireTime
610
+ : 10000,
611
+ maximumAge: 0,
612
+ },
613
+ );
614
+ });
615
+ },
616
+ }),
617
+ createHostApiFeature({
618
+ apiName: 'getScreenBrightness',
619
+ description: HOST_API_DESCRIPTIONS.getScreenBrightness,
620
+ handle: async () => createHostApiSuccess(await getBrightness()),
621
+ }),
622
+ createHostApiFeature({
623
+ apiName: 'getSystemInfo',
624
+ description: HOST_API_DESCRIPTIONS.getSystemInfo,
625
+ handle: async () => createHostApiSuccess(buildSystemInfo()),
626
+ }),
627
+ createHostApiFeature({
628
+ apiName: 'makePhoneCall',
629
+ description: HOST_API_DESCRIPTIONS.makePhoneCall,
630
+ handle: async payload => {
631
+ const phoneNumber = String(payload.phoneNumber ?? '');
632
+ if (!phoneNumber) {
633
+ return createHostApiSuccess(false);
634
+ }
635
+ const telUrl = `tel:${phoneNumber}`;
636
+ try {
637
+ await Linking.openURL(telUrl);
638
+ return createHostApiSuccess(true);
639
+ } catch {
640
+ return createHostApiSuccess(false);
641
+ }
642
+ },
643
+ }),
644
+ createHostApiFeature({
645
+ apiName: 'getImageInfo',
646
+ description: HOST_API_DESCRIPTIONS.getImageInfo,
647
+ handle: async payload =>
648
+ new Promise(resolve => {
649
+ Image.getSize(
650
+ String(payload.src ?? ''),
651
+ (width, height) => {
652
+ resolve(
653
+ createHostApiSuccess({
654
+ width,
655
+ height,
656
+ path: String(payload.src ?? ''),
657
+ orientation: 'up',
658
+ type: '',
659
+ errMsg: 'getImageInfo:ok',
660
+ }),
661
+ );
662
+ },
663
+ err => {
664
+ resolve(
665
+ createHostApiFailure(
666
+ 'GET_IMAGE_INFO_FAILED',
667
+ err.message || 'getImageInfo:fail',
668
+ ),
669
+ );
670
+ },
671
+ );
672
+ }),
673
+ }),
674
+ createHostApiFeature({
675
+ apiName: 'compressImage',
676
+ description: HOST_API_DESCRIPTIONS.compressImage,
677
+ handle: async payload => {
678
+ const result = await ImageResizer.createResizedImage(
679
+ String(payload.src ?? ''),
680
+ Number(payload.compressedWidth ?? 0),
681
+ Number(payload.compressedHeight ?? 0),
682
+ 'JPEG',
683
+ Number(payload.quality ?? 80),
684
+ 0,
685
+ null,
686
+ );
687
+ return createHostApiSuccess(result.uri);
688
+ },
689
+ }),
690
+ createHostApiFeature({
691
+ apiName: 'getFileInfo',
692
+ description: HOST_API_DESCRIPTIONS.getFileInfo,
693
+ handle: async payload => {
694
+ const filePath = String(payload.filePath ?? '');
695
+ const digestAlgorithm = (payload.digestAlgorithm ?? 'md5') as
696
+ 'md5' | 'sha1' | 'sha256';
697
+ const stat = await RNFS.stat(filePath);
698
+ const digest = await RNFS.hash(filePath, digestAlgorithm);
699
+ return createHostApiSuccess({
700
+ size: stat.size,
701
+ digest,
702
+ });
703
+ },
704
+ }),
705
+ createHostApiFeature({
706
+ apiName: 'chooseMedia',
707
+ description: HOST_API_DESCRIPTIONS.chooseMedia,
708
+ handle: async payload =>
709
+ createHostApiSuccess(await chooseMediaHost(payload as ChooseMediaOption)),
710
+ }),
711
+ createHostApiFeature({
712
+ apiName: 'saveMedia',
713
+ description: HOST_API_DESCRIPTIONS.saveMedia,
714
+ handle: async payload =>
715
+ createHostApiSuccess(
716
+ await saveMediaHost({
717
+ url: String(payload.url ?? ''),
718
+ type: (payload.type as 'photo' | 'video') ?? 'photo',
719
+ album: typeof payload.album === 'string' ? payload.album : undefined,
720
+ }),
721
+ ),
722
+ }),
723
+ createHostApiFeature({
724
+ apiName: 'storage.setItem',
725
+ description: HOST_API_DESCRIPTIONS['storage.setItem'],
726
+ handle: async payload => {
727
+ hostStorage.set(String(payload.key ?? ''), String(payload.data ?? ''));
728
+ return createHostApiSuccess(null);
729
+ },
730
+ }),
731
+ createHostApiFeature({
732
+ apiName: 'storage.getItem',
733
+ description: HOST_API_DESCRIPTIONS['storage.getItem'],
734
+ handle: async payload =>
735
+ createHostApiSuccess({
736
+ value: hostStorage.getString(String(payload.key ?? '')),
737
+ }),
738
+ }),
739
+ createHostApiFeature({
740
+ apiName: 'storage.removeItem',
741
+ description: HOST_API_DESCRIPTIONS['storage.removeItem'],
742
+ handle: async payload => {
743
+ hostStorage.remove(String(payload.key ?? ''));
744
+ return createHostApiSuccess(null);
745
+ },
746
+ }),
747
+ createHostApiFeature({
748
+ apiName: 'storage.clearItems',
749
+ description: HOST_API_DESCRIPTIONS['storage.clearItems'],
750
+ handle: async () => {
751
+ hostStorage.clearAll();
752
+ hostStorage.trim();
753
+ return createHostApiSuccess(null);
754
+ },
755
+ }),
756
+ createHostApiFeature({
757
+ apiName: 'storage.getKeys',
758
+ description: HOST_API_DESCRIPTIONS['storage.getKeys'],
759
+ handle: async () => createHostApiSuccess({ keys: getStorageKeys() }),
760
+ }),
761
+ createHostApiFeature({
762
+ apiName: 'storage.getCurrentSize',
763
+ description: HOST_API_DESCRIPTIONS['storage.getCurrentSize'],
764
+ handle: async () =>
765
+ createHostApiSuccess({
766
+ size: getStorageCurrentSize(),
767
+ }),
768
+ }),
769
+ createHostApiFeature({
770
+ apiName: 'getNetworkType',
771
+ description: HOST_API_DESCRIPTIONS.getNetworkType,
772
+ handle: async () => {
773
+ const state = await NetInfo.fetch();
774
+ return createHostApiSuccess({
775
+ networkType: state.type,
776
+ });
777
+ },
778
+ }),
779
+ createHostApiFeature({
780
+ apiName: 'getMiniAppUpdateInfo',
781
+ description: HOST_API_DESCRIPTIONS.getMiniAppUpdateInfo,
782
+ handle: async (_payload, context) => {
783
+ return createHostApiSuccess(
784
+ await NebulaAPI.checkMiniAppUpdate(context.appId),
785
+ );
786
+ },
787
+ }),
788
+ createHostApiFeature({
789
+ apiName: 'applyMiniAppUpdate',
790
+ description: HOST_API_DESCRIPTIONS.applyMiniAppUpdate,
791
+ handle: async (_payload, context) => {
792
+ return createHostApiSuccess(
793
+ await NebulaAPI.applyMiniAppUpdate(context.appId),
794
+ );
795
+ },
796
+ }),
797
+ createHostApiFeature({
798
+ apiName: 'locationChange.subscribe',
799
+ description: HOST_API_DESCRIPTIONS['locationChange.subscribe'],
800
+ handle: async (payload, context) => {
801
+ const subscriptionId = createHostBridgeId('locationChange');
802
+ const watchId = Geolocation.watchPosition(
803
+ ({ coords, timestamp }) => {
804
+ emitSubscriptionEvent(
805
+ context.appId,
806
+ 'locationChange',
807
+ subscriptionId,
808
+ {
809
+ accuracy: coords.accuracy,
810
+ altitude: coords.altitude,
811
+ latitude: coords.latitude,
812
+ longitude: coords.longitude,
813
+ speed: coords.speed,
814
+ timestamp,
815
+ },
816
+ ).catch(() => {});
817
+ },
818
+ () => {},
819
+ {
820
+ timeout: 10000,
821
+ maximumAge: 0,
822
+ enableHighAccuracy:
823
+ typeof payload.enableHighAccuracy === 'boolean'
824
+ ? payload.enableHighAccuracy
825
+ : true,
826
+ distanceFilter: 0,
827
+ },
828
+ );
829
+ locationSubscriptions.set(subscriptionId, watchId);
830
+ return createSubscriptionStartResult(subscriptionId);
831
+ },
832
+ }),
833
+ createHostApiFeature({
834
+ apiName: 'locationChange.unsubscribe',
835
+ description: HOST_API_DESCRIPTIONS['locationChange.unsubscribe'],
836
+ handle: async payload => {
837
+ const subscriptionId = String(payload.subscriptionId ?? '');
838
+ const watchId = locationSubscriptions.get(subscriptionId);
839
+ if (typeof watchId === 'number') {
840
+ Geolocation.clearWatch(watchId);
841
+ }
842
+ locationSubscriptions.delete(subscriptionId);
843
+ return createHostApiSuccess(null);
844
+ },
845
+ }),
846
+ createHostApiFeature({
847
+ apiName: 'networkStatusChange.subscribe',
848
+ description: HOST_API_DESCRIPTIONS['networkStatusChange.subscribe'],
849
+ handle: async (_payload, context) => {
850
+ const subscriptionId = createHostBridgeId('networkStatusChange');
851
+ const unsubscribe = NetInfo.addEventListener(state => {
852
+ emitSubscriptionEvent(
853
+ context.appId,
854
+ 'networkStatusChange',
855
+ subscriptionId,
856
+ {
857
+ isConnected: state.isConnected ?? false,
858
+ networkType: state.type,
859
+ },
860
+ ).catch(() => {});
861
+ });
862
+ networkSubscriptions.set(subscriptionId, unsubscribe);
863
+ return createSubscriptionStartResult(subscriptionId);
864
+ },
865
+ }),
866
+ createHostApiFeature({
867
+ apiName: 'networkStatusChange.unsubscribe',
868
+ description: HOST_API_DESCRIPTIONS['networkStatusChange.unsubscribe'],
869
+ handle: async payload => {
870
+ const subscriptionId = String(payload.subscriptionId ?? '');
871
+ networkSubscriptions.get(subscriptionId)?.();
872
+ networkSubscriptions.delete(subscriptionId);
873
+ return createHostApiSuccess(null);
874
+ },
875
+ }),
876
+ createHostApiFeature({
877
+ apiName: 'userCaptureScreen.subscribe',
878
+ description: HOST_API_DESCRIPTIONS['userCaptureScreen.subscribe'],
879
+ handle: async (_payload, context) => {
880
+ const subscriptionId = createHostBridgeId('userCaptureScreen');
881
+ const subscription = ScreenshotAware.addListener(() => {
882
+ emitSubscriptionEvent(
883
+ context.appId,
884
+ 'userCaptureScreen',
885
+ subscriptionId,
886
+ null,
887
+ ).catch(() => {});
888
+ });
889
+ screenshotSubscriptions.set(subscriptionId, subscription);
890
+ return createSubscriptionStartResult(subscriptionId);
891
+ },
892
+ }),
893
+ createHostApiFeature({
894
+ apiName: 'userCaptureScreen.unsubscribe',
895
+ description: HOST_API_DESCRIPTIONS['userCaptureScreen.unsubscribe'],
896
+ handle: async payload => {
897
+ const subscriptionId = String(payload.subscriptionId ?? '');
898
+ screenshotSubscriptions.get(subscriptionId)?.remove();
899
+ screenshotSubscriptions.delete(subscriptionId);
900
+ return createHostApiSuccess(null);
901
+ },
902
+ }),
903
+ ...createSensorSubscriptionFeature('accelerometerChange', interval => {
904
+ setUpdateIntervalForType(SensorTypes.accelerometer, interval ?? 100);
905
+ return accelerometer;
906
+ }),
907
+ ...createSensorSubscriptionFeature('gyroscopeChange', interval => {
908
+ setUpdateIntervalForType(SensorTypes.gyroscope, interval ?? 100);
909
+ return gyroscope;
910
+ }),
911
+ ...createSensorSubscriptionFeature('magnetometerChange', interval => {
912
+ setUpdateIntervalForType(SensorTypes.magnetometer, interval ?? 100);
913
+ return magnetometer;
914
+ }),
915
+ ...createSensorSubscriptionFeature('barometerChange', () => barometer),
916
+ createHostApiFeature({
917
+ apiName: 'fileSystem.access',
918
+ description: HOST_API_DESCRIPTIONS['fileSystem.access'],
919
+ handle: async (payload, context) => {
920
+ const exists = await RNFS.exists(
921
+ resolveMiniAppPath(payload.path, context.appId),
922
+ );
923
+ if (!exists) {
924
+ return createHostApiFailure(
925
+ 'FILE_NOT_FOUND',
926
+ 'access:fail no such file or directory',
927
+ );
928
+ }
929
+ return createHostApiSuccess(null);
930
+ },
931
+ }),
932
+ createHostApiFeature({
933
+ apiName: 'fileSystem.appendFile',
934
+ description: HOST_API_DESCRIPTIONS['fileSystem.appendFile'],
935
+ handle: async (payload, context) => {
936
+ await RNFS.appendFile(
937
+ resolveMiniAppPath(payload.filePath, context.appId),
938
+ String(payload.data ?? ''),
939
+ (payload.encoding as RNFS.EncodingT | undefined) ?? 'utf8',
940
+ );
941
+ return createHostApiSuccess(null);
942
+ },
943
+ }),
944
+ createHostApiFeature({
945
+ apiName: 'fileSystem.saveFile',
946
+ description: HOST_API_DESCRIPTIONS['fileSystem.saveFile'],
947
+ handle: async (payload, context) => {
948
+ const tempFilePath = resolveMiniAppPath(
949
+ payload.tempFilePath,
950
+ context.appId,
951
+ );
952
+ const filePath =
953
+ typeof payload.filePath === 'string' && payload.filePath.length > 0
954
+ ? resolveMiniAppPath(payload.filePath, context.appId)
955
+ : `${RNFS.DocumentDirectoryPath}/${Date.now()}`;
956
+ await RNFS.moveFile(tempFilePath, filePath);
957
+ return createHostApiSuccess({ savedFilePath: filePath });
958
+ },
959
+ }),
960
+ createHostApiFeature({
961
+ apiName: 'fileSystem.copyFile',
962
+ description: HOST_API_DESCRIPTIONS['fileSystem.copyFile'],
963
+ handle: async (payload, context) => {
964
+ await RNFS.copyFile(
965
+ resolveMiniAppPath(payload.srcPath, context.appId),
966
+ resolveMiniAppPath(payload.destPath, context.appId),
967
+ );
968
+ return createHostApiSuccess(null);
969
+ },
970
+ }),
971
+ createHostApiFeature({
972
+ apiName: 'fileSystem.mkdir',
973
+ description: HOST_API_DESCRIPTIONS['fileSystem.mkdir'],
974
+ handle: async (payload, context) => {
975
+ await RNFS.mkdir(resolveMiniAppPath(payload.dirPath, context.appId));
976
+ return createHostApiSuccess(null);
977
+ },
978
+ }),
979
+ createHostApiFeature({
980
+ apiName: 'fileSystem.readFile',
981
+ description: HOST_API_DESCRIPTIONS['fileSystem.readFile'],
982
+ handle: async (payload, context) => {
983
+ const data = await RNFS.readFile(
984
+ resolveMiniAppPath(payload.filePath, context.appId),
985
+ (payload.encoding as RNFS.EncodingT | undefined) ?? 'utf8',
986
+ );
987
+ return createHostApiSuccess({ data });
988
+ },
989
+ }),
990
+ createHostApiFeature({
991
+ apiName: 'fileSystem.readdir',
992
+ description: HOST_API_DESCRIPTIONS['fileSystem.readdir'],
993
+ handle: async (payload, context) => {
994
+ const files = await RNFS.readdir(
995
+ resolveMiniAppPath(payload.dirPath, context.appId),
996
+ );
997
+ return createHostApiSuccess({ files });
998
+ },
999
+ }),
1000
+ createHostApiFeature({
1001
+ apiName: 'fileSystem.rename',
1002
+ description: HOST_API_DESCRIPTIONS['fileSystem.rename'],
1003
+ handle: async (payload, context) => {
1004
+ await RNFS.moveFile(
1005
+ resolveMiniAppPath(payload.oldPath, context.appId),
1006
+ resolveMiniAppPath(payload.newPath, context.appId),
1007
+ );
1008
+ return createHostApiSuccess(null);
1009
+ },
1010
+ }),
1011
+ createHostApiFeature({
1012
+ apiName: 'fileSystem.rmdir',
1013
+ description: HOST_API_DESCRIPTIONS['fileSystem.rmdir'],
1014
+ handle: async (payload, context) => {
1015
+ await RNFS.unlink(resolveMiniAppPath(payload.dirPath, context.appId));
1016
+ return createHostApiSuccess(null);
1017
+ },
1018
+ }),
1019
+ createHostApiFeature({
1020
+ apiName: 'fileSystem.unlink',
1021
+ description: HOST_API_DESCRIPTIONS['fileSystem.unlink'],
1022
+ handle: async (payload, context) => {
1023
+ await RNFS.unlink(resolveMiniAppPath(payload.filePath, context.appId));
1024
+ return createHostApiSuccess(null);
1025
+ },
1026
+ }),
1027
+ createHostApiFeature({
1028
+ apiName: 'fileSystem.writeFile',
1029
+ description: HOST_API_DESCRIPTIONS['fileSystem.writeFile'],
1030
+ handle: async (payload, context) => {
1031
+ await RNFS.writeFile(
1032
+ resolveMiniAppPath(payload.filePath, context.appId),
1033
+ String(payload.data ?? ''),
1034
+ (payload.encoding as RNFS.EncodingT | undefined) ?? 'utf8',
1035
+ );
1036
+ return createHostApiSuccess(null);
1037
+ },
1038
+ }),
1039
+ createHostApiFeature({
1040
+ apiName: 'fileSystem.getFileInfo',
1041
+ description: HOST_API_DESCRIPTIONS['fileSystem.getFileInfo'],
1042
+ handle: async (payload, context) => {
1043
+ const filePath = resolveMiniAppPath(payload.filePath, context.appId);
1044
+ const digestAlgorithm = (payload.digestAlgorithm ?? 'md5') as
1045
+ 'md5' | 'sha1' | 'sha256';
1046
+ const stat = await RNFS.stat(filePath);
1047
+ const digest = await RNFS.hash(filePath, digestAlgorithm);
1048
+ return createHostApiSuccess({
1049
+ size: stat.size,
1050
+ digest,
1051
+ });
1052
+ },
1053
+ }),
1054
+ createHostApiFeature({
1055
+ apiName: 'removeFile',
1056
+ description: HOST_API_DESCRIPTIONS.removeFile,
1057
+ handle: async (payload, context) => {
1058
+ const filePath = resolveMiniAppPath(payload.filePath, context.appId);
1059
+ const exists = await RNFS.exists(filePath);
1060
+ if (!exists) {
1061
+ return createHostApiFailure(
1062
+ 'FILE_NOT_FOUND',
1063
+ 'removeFile:fail no such file or directory',
1064
+ );
1065
+ }
1066
+ await RNFS.unlink(filePath);
1067
+ return createHostApiSuccess(null);
1068
+ },
1069
+ }),
1070
+ ...downloadFileHostApi,
1071
+ ...uploadFileHostApi,
1072
+ ];