@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.
- package/LICENSE +21 -0
- package/README.md +20 -0
- package/package.json +46 -0
- package/runtime/App_Resources/Android/app.gradle +34 -0
- package/runtime/App_Resources/Android/before-plugins.gradle +15 -0
- package/runtime/App_Resources/Android/src/main/AndroidManifest.xml +76 -0
- package/runtime/App_Resources/Android/src/main/res/drawable/ic_launcher_foreground.xml +15 -0
- package/runtime/App_Resources/Android/src/main/res/drawable-hdpi/background.png +0 -0
- package/runtime/App_Resources/Android/src/main/res/drawable-hdpi/logo.png +0 -0
- package/runtime/App_Resources/Android/src/main/res/drawable-ldpi/background.png +0 -0
- package/runtime/App_Resources/Android/src/main/res/drawable-ldpi/logo.png +0 -0
- package/runtime/App_Resources/Android/src/main/res/drawable-mdpi/background.png +0 -0
- package/runtime/App_Resources/Android/src/main/res/drawable-mdpi/logo.png +0 -0
- package/runtime/App_Resources/Android/src/main/res/drawable-nodpi/splash_screen.xml +8 -0
- package/runtime/App_Resources/Android/src/main/res/drawable-xhdpi/background.png +0 -0
- package/runtime/App_Resources/Android/src/main/res/drawable-xhdpi/logo.png +0 -0
- package/runtime/App_Resources/Android/src/main/res/drawable-xxhdpi/background.png +0 -0
- package/runtime/App_Resources/Android/src/main/res/drawable-xxhdpi/logo.png +0 -0
- package/runtime/App_Resources/Android/src/main/res/drawable-xxxhdpi/background.png +0 -0
- package/runtime/App_Resources/Android/src/main/res/drawable-xxxhdpi/logo.png +0 -0
- package/runtime/App_Resources/Android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +5 -0
- package/runtime/App_Resources/Android/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
- package/runtime/App_Resources/Android/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
- package/runtime/App_Resources/Android/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
- package/runtime/App_Resources/Android/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
- package/runtime/App_Resources/Android/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
- package/runtime/App_Resources/Android/src/main/res/values/colors.xml +14 -0
- package/runtime/App_Resources/Android/src/main/res/values/ic_launcher_background.xml +4 -0
- package/runtime/App_Resources/Android/src/main/res/values/styles.xml +42 -0
- package/runtime/App_Resources/Android/src/main/res/values-v21/colors.xml +5 -0
- package/runtime/App_Resources/Android/src/main/res/values-v21/styles.xml +32 -0
- package/runtime/App_Resources/Android/src/main/res/values-v29/styles.xml +18 -0
- package/runtime/App_Resources/Android/src/main/res/xml/file_paths.xml +5 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/Contents.json +122 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-1024.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-20.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-20@2x.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-20@3x.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-29.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-29@2x.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-29@3x.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-40.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-40@2x.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-40@3x.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-60@2x.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-60@3x.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-76.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-76@2x.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/AppIcon.appiconset/icon-83.5@2x.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/Contents.json +6 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/LaunchScreen.AspectFill.imageset/Contents.json +23 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/LaunchScreen.AspectFill.imageset/LaunchScreen-AspectFill.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/LaunchScreen.AspectFill.imageset/LaunchScreen-AspectFill@2x.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/LaunchScreen.AspectFill.imageset/LaunchScreen-AspectFill@3x.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/LaunchScreen.Center.imageset/Contents.json +23 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/LaunchScreen.Center.imageset/LaunchScreen-Center.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/LaunchScreen.Center.imageset/LaunchScreen-Center@2x.png +0 -0
- package/runtime/App_Resources/iOS/Assets.xcassets/LaunchScreen.Center.imageset/LaunchScreen-Center@3x.png +0 -0
- package/runtime/App_Resources/iOS/Info.plist +60 -0
- package/runtime/App_Resources/iOS/LaunchScreen.storyboard +53 -0
- package/runtime/App_Resources/iOS/Podfile +13 -0
- package/runtime/App_Resources/iOS/PrivacyInfo.xcprivacy +59 -0
- package/runtime/App_Resources/iOS/build.xcconfig +7 -0
- package/runtime/app/app.ts +58 -0
- package/runtime/app/main-page.ts +116 -0
- package/runtime/app/main-page.xml +10 -0
- package/runtime/app/shell/active-modules.generated.ts +9 -0
- package/runtime/app/shell/android-helpers.ts +76 -0
- package/runtime/app/shell/bridge.ts +99 -0
- package/runtime/app/shell/capabilities.manifest.ts +207 -0
- package/runtime/app/shell/config.ts +35 -0
- package/runtime/app/shell/custom-webview.android.ts +270 -0
- package/runtime/app/shell/custom-webview.ios.ts +258 -0
- package/runtime/app/shell/custom-webview.ts +17 -0
- package/runtime/app/shell/env.ts +81 -0
- package/runtime/app/shell/events.ts +100 -0
- package/runtime/app/shell/fcm-bootstrap.generated.ts +1 -0
- package/runtime/app/shell/fcm-firebase.d.ts +16 -0
- package/runtime/app/shell/fcm-service.android.ts +40 -0
- package/runtime/app/shell/fcm-service.ios.ts +3 -0
- package/runtime/app/shell/handlers-android.ts +417 -0
- package/runtime/app/shell/handlers-billing.ts +197 -0
- package/runtime/app/shell/handlers-extended.ts +405 -0
- package/runtime/app/shell/handlers-health.ts +134 -0
- package/runtime/app/shell/handlers-media.ts +126 -0
- package/runtime/app/shell/handlers-oauth.ts +69 -0
- package/runtime/app/shell/handlers-parity.ts +257 -0
- package/runtime/app/shell/handlers-push.ts +189 -0
- package/runtime/app/shell/handlers-system.ts +70 -0
- package/runtime/app/shell/handlers.ts +149 -0
- package/runtime/app/shell/ios-frameworks.d.ts +36 -0
- package/runtime/app/shell/ios-image.ts +15 -0
- package/runtime/app/shell/ios-ui-delegate.ts +131 -0
- package/runtime/app/shell/mime.ts +29 -0
- package/runtime/app/shell/native-log.ts +22 -0
- package/runtime/app/shell/optional-handlers.generated.ts +10 -0
- package/runtime/app/shell/orientation.ts +43 -0
- package/runtime/app/shell/status-bar.ts +129 -0
- package/runtime/app/shell/toast.ts +67 -0
- package/runtime/app/shell/web-quirks.ts +131 -0
- package/runtime/modules-native/health/App_Resources/Android/src/main/java/cc/livx/appwrap/HealthConnectBridge.kt +74 -0
- package/runtime/nativescript.config.ts +15 -0
- package/runtime/package.json +21 -0
- package/runtime/references.d.ts +2 -0
- package/runtime/tsconfig.json +19 -0
- package/runtime/webpack.config.js +27 -0
- package/scripts/stage-assets.mjs +21 -0
- package/src/cli.ts +1328 -0
- package/templates/ci/fastlane/Fastfile +43 -0
- package/templates/ci/fastlane/Matchfile +3 -0
- package/templates/ci/github/workflows/appwrap-pr.yml +28 -0
- package/templates/ci/github/workflows/appwrap-release-android.yml +51 -0
- 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
|
+
}
|