@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,69 @@
|
|
|
1
|
+
import { Utils, isIOS } from '@nativescript/core';
|
|
2
|
+
import { bridge } from './bridge';
|
|
3
|
+
|
|
4
|
+
// AuthenticationServices globals (NS auto-links the framework via metadata when referenced).
|
|
5
|
+
declare const ASWebAuthenticationSession: any;
|
|
6
|
+
declare const ASWebAuthenticationPresentationContextProviding: any;
|
|
7
|
+
|
|
8
|
+
const err = (code: string, message: string) => Object.assign(new Error(message), { code });
|
|
9
|
+
|
|
10
|
+
// Keep strong refs while the sheet is up — ARC frees the session + its context provider the moment
|
|
11
|
+
// the JS locals fall out of scope, which silently cancels the flow mid-handshake.
|
|
12
|
+
let activeSession: any = null;
|
|
13
|
+
let activeProvider: any = null;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* System-browser OAuth (iOS ASWebAuthenticationSession). Runs the provider's auth page in the
|
|
17
|
+
* OS-shared, policy-compliant browser and returns the redirect callback URL to JS — so Google et al.
|
|
18
|
+
* don't reject it as an embedded WebView (403 disallowed_useragent). Provider-agnostic: the PWA builds
|
|
19
|
+
* the auth URL + does token exchange; this only opens the browser and hands back the callback.
|
|
20
|
+
*/
|
|
21
|
+
export function registerOAuthHandlers(): void {
|
|
22
|
+
if (!isIOS) return;
|
|
23
|
+
|
|
24
|
+
bridge.register(
|
|
25
|
+
'oauth.authorize',
|
|
26
|
+
({ url, callbackScheme, ephemeral }: { url: string; callbackScheme: string; ephemeral?: boolean }) =>
|
|
27
|
+
new Promise((resolve, reject) => {
|
|
28
|
+
const target = String(url ?? '');
|
|
29
|
+
const scheme = String(callbackScheme ?? '');
|
|
30
|
+
if (!target) { reject(err('NATIVE_ERROR', 'oauth.authorize: empty url')); return; }
|
|
31
|
+
if (!scheme) { reject(err('NATIVE_ERROR', 'oauth.authorize: empty callbackScheme')); return; }
|
|
32
|
+
|
|
33
|
+
Utils.dispatchToMainThread(() => {
|
|
34
|
+
const session = ASWebAuthenticationSession.alloc().initWithURLCallbackURLSchemeCompletionHandler(
|
|
35
|
+
NSURL.URLWithString(target),
|
|
36
|
+
scheme,
|
|
37
|
+
(callbackURL: any, error: any) => {
|
|
38
|
+
activeSession = null;
|
|
39
|
+
activeProvider = null;
|
|
40
|
+
if (error) {
|
|
41
|
+
// ASWebAuthenticationSessionErrorCodeCanceledLogin === 1 (user dismissed the sheet).
|
|
42
|
+
const code = error.code === 1 ? 'CANCELLED' : 'NATIVE_ERROR';
|
|
43
|
+
reject(err(code, error.localizedDescription ?? 'oauth failed'));
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
resolve({ url: callbackURL ? String(callbackURL.absoluteString) : '' });
|
|
47
|
+
}
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const ProviderClass = (NSObject as any).extend(
|
|
51
|
+
{
|
|
52
|
+
presentationAnchorForWebAuthenticationSession(_session: any): any {
|
|
53
|
+
return (
|
|
54
|
+
Utils.ios.getRootViewController()?.view?.window ??
|
|
55
|
+
UIApplication.sharedApplication.keyWindow
|
|
56
|
+
);
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
{ protocols: [ASWebAuthenticationPresentationContextProviding] }
|
|
60
|
+
);
|
|
61
|
+
activeProvider = ProviderClass.new();
|
|
62
|
+
session.presentationContextProvider = activeProvider;
|
|
63
|
+
session.prefersEphemeralWebBrowserSession = !!ephemeral;
|
|
64
|
+
activeSession = session;
|
|
65
|
+
session.start();
|
|
66
|
+
});
|
|
67
|
+
})
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { Application, ApplicationSettings, Color, Dialogs, Utils, isIOS } from '@nativescript/core';
|
|
2
|
+
import { bridge } from './bridge';
|
|
3
|
+
import { uiImageToDataUrl } from './ios-image';
|
|
4
|
+
|
|
5
|
+
const err = (code: string, message: string) => Object.assign(new Error(message), { code });
|
|
6
|
+
const iosOnly = () => err('UNSUPPORTED', 'iOS only for now');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Extended-parity handlers: dialogs, reviews, storage.clear, geo watch, theme color,
|
|
10
|
+
* motion, contacts, calendar, camera. iOS-first like handlers-extended.
|
|
11
|
+
*/
|
|
12
|
+
export function registerParityHandlers(): void {
|
|
13
|
+
// ── dialogs (NS Dialogs — cross-platform) ──────────────────────────
|
|
14
|
+
bridge.register('ui.alert', ({ title, message, ok }: any) =>
|
|
15
|
+
Dialogs.alert({ title, message: String(message ?? ''), okButtonText: ok ?? 'OK' }).then(() => undefined)
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
bridge.register('ui.confirm', ({ title, message, ok, cancel }: any) =>
|
|
19
|
+
Dialogs.confirm({
|
|
20
|
+
title,
|
|
21
|
+
message: String(message ?? ''),
|
|
22
|
+
okButtonText: ok ?? 'OK',
|
|
23
|
+
cancelButtonText: cancel ?? 'Cancel',
|
|
24
|
+
}).then((r) => !!r)
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
bridge.register('ui.action', async ({ title, options, cancel }: any) => {
|
|
28
|
+
const cancelText = cancel ?? 'Cancel';
|
|
29
|
+
const picked = await Dialogs.action({ title, actions: options ?? [], cancelButtonText: cancelText });
|
|
30
|
+
const idx = (options ?? []).indexOf(picked);
|
|
31
|
+
return idx >= 0 ? idx : null;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// ── reviews (SKStoreReviewController) ──────────────────────────────
|
|
35
|
+
bridge.register('reviews.requestReview', () => {
|
|
36
|
+
if (!isIOS) throw iosOnly();
|
|
37
|
+
Utils.dispatchToMainThread(() => {
|
|
38
|
+
const scene = UIApplication.sharedApplication.keyWindow?.windowScene;
|
|
39
|
+
if (scene && SKStoreReviewController.requestReviewInWindowScene) {
|
|
40
|
+
SKStoreReviewController.requestReviewInWindowScene(scene);
|
|
41
|
+
} else {
|
|
42
|
+
SKStoreReviewController.requestReview();
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// ── storage.clear ──────────────────────────────────────────────────
|
|
48
|
+
bridge.register('storage.clear', () => {
|
|
49
|
+
for (const key of ApplicationSettings.getAllKeys()) {
|
|
50
|
+
if (key.startsWith('kit:')) ApplicationSettings.remove(key);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// ── theme color → native chrome behind the page ────────────────────
|
|
55
|
+
bridge.register('ui.setBackgroundColor', ({ color }: { color: string }) => {
|
|
56
|
+
Utils.dispatchToMainThread(() => {
|
|
57
|
+
const root = Application.getRootView();
|
|
58
|
+
if (root) root.backgroundColor = new Color(String(color));
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// ── geo.watch (persistent CLLocationManager) ───────────────────────
|
|
63
|
+
let geoManager: any = null;
|
|
64
|
+
let geoDelegate: any = null;
|
|
65
|
+
|
|
66
|
+
bridge.register('geo.watch.start', () => {
|
|
67
|
+
if (!isIOS) throw iosOnly();
|
|
68
|
+
if (geoManager) return; // already streaming
|
|
69
|
+
const DelegateClass = (NSObject as any).extend(
|
|
70
|
+
{
|
|
71
|
+
locationManagerDidUpdateLocations(_m: CLLocationManager, locations: NSArray<CLLocation>) {
|
|
72
|
+
const loc = locations.lastObject;
|
|
73
|
+
if (!loc) return;
|
|
74
|
+
bridge.emit('geo.position', {
|
|
75
|
+
lat: loc.coordinate.latitude,
|
|
76
|
+
lng: loc.coordinate.longitude,
|
|
77
|
+
accuracy: loc.horizontalAccuracy,
|
|
78
|
+
});
|
|
79
|
+
},
|
|
80
|
+
locationManagerDidFailWithError(_m: CLLocationManager, error: NSError) {
|
|
81
|
+
console.warn('AppWrap: geo.watch error', error.localizedDescription);
|
|
82
|
+
},
|
|
83
|
+
locationManagerDidChangeAuthorization(m: CLLocationManager) {
|
|
84
|
+
const st = m.authorizationStatus;
|
|
85
|
+
if (st === CLAuthorizationStatus.kCLAuthorizationStatusAuthorizedWhenInUse ||
|
|
86
|
+
st === CLAuthorizationStatus.kCLAuthorizationStatusAuthorizedAlways) {
|
|
87
|
+
m.startUpdatingLocation();
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
{ protocols: [CLLocationManagerDelegate] }
|
|
92
|
+
);
|
|
93
|
+
Utils.dispatchToMainThread(() => {
|
|
94
|
+
geoManager = CLLocationManager.new();
|
|
95
|
+
geoDelegate = DelegateClass.new();
|
|
96
|
+
geoManager.delegate = geoDelegate;
|
|
97
|
+
geoManager.desiredAccuracy = kCLLocationAccuracyHundredMeters;
|
|
98
|
+
const st = geoManager.authorizationStatus;
|
|
99
|
+
if (st === CLAuthorizationStatus.kCLAuthorizationStatusNotDetermined) {
|
|
100
|
+
geoManager.requestWhenInUseAuthorization();
|
|
101
|
+
} else {
|
|
102
|
+
geoManager.startUpdatingLocation();
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
bridge.register('geo.watch.stop', () => {
|
|
108
|
+
geoManager?.stopUpdatingLocation();
|
|
109
|
+
geoManager = null;
|
|
110
|
+
geoDelegate = null;
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// ── motion (CMMotionManager) ───────────────────────────────────────
|
|
114
|
+
// Poll the latest sample on a JS timer rather than CoreMotion's queue-handler
|
|
115
|
+
// block: the repeated JS↔Obj-C block is fragile under NativeScript (the handler
|
|
116
|
+
// can silently stop firing on-device). startDeviceMotionUpdates() + reading
|
|
117
|
+
// `mm.deviceMotion` is a plain property read on the main thread — reliable.
|
|
118
|
+
let motionManager: any = null;
|
|
119
|
+
let motionTimer: any = null;
|
|
120
|
+
|
|
121
|
+
bridge.register('motion.start', () => {
|
|
122
|
+
if (!isIOS) throw iosOnly();
|
|
123
|
+
if (motionManager) return;
|
|
124
|
+
const mm = CMMotionManager.new();
|
|
125
|
+
if (!mm.deviceMotionAvailable) throw err('UNSUPPORTED', 'No motion sensors (simulator?)');
|
|
126
|
+
motionManager = mm;
|
|
127
|
+
mm.deviceMotionUpdateInterval = 0.1;
|
|
128
|
+
mm.startDeviceMotionUpdates();
|
|
129
|
+
const G = 9.81; // CoreMotion reports in g — kit contract is m/s²
|
|
130
|
+
motionTimer = setInterval(() => {
|
|
131
|
+
const m = mm.deviceMotion;
|
|
132
|
+
if (!m) return; // first sample not ready yet
|
|
133
|
+
bridge.emit('motion.data', {
|
|
134
|
+
ax: (m.userAcceleration.x + m.gravity.x) * G,
|
|
135
|
+
ay: (m.userAcceleration.y + m.gravity.y) * G,
|
|
136
|
+
az: (m.userAcceleration.z + m.gravity.z) * G,
|
|
137
|
+
rx: m.rotationRate.x,
|
|
138
|
+
ry: m.rotationRate.y,
|
|
139
|
+
rz: m.rotationRate.z,
|
|
140
|
+
});
|
|
141
|
+
}, 100);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
bridge.register('motion.stop', () => {
|
|
145
|
+
if (motionTimer) clearInterval(motionTimer);
|
|
146
|
+
motionTimer = null;
|
|
147
|
+
motionManager?.stopDeviceMotionUpdates();
|
|
148
|
+
motionManager = null;
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// ── contacts (CNContactPickerViewController — no permission needed) ──
|
|
152
|
+
bridge.register('contacts.pick', () => {
|
|
153
|
+
if (!isIOS) throw iosOnly();
|
|
154
|
+
return new Promise((resolve) => {
|
|
155
|
+
Utils.dispatchToMainThread(() => {
|
|
156
|
+
const DelegateClass = (NSObject as any).extend(
|
|
157
|
+
{
|
|
158
|
+
contactPickerDidSelectContact(_picker: any, contact: any) {
|
|
159
|
+
const collect = (labeled: NSArray<any>, map: (v: any) => string) => {
|
|
160
|
+
const out: string[] = [];
|
|
161
|
+
for (let i = 0; i < labeled.count; i++) out.push(map(labeled.objectAtIndex(i).value));
|
|
162
|
+
return out;
|
|
163
|
+
};
|
|
164
|
+
resolve({
|
|
165
|
+
picked: true,
|
|
166
|
+
name: `${contact.givenName ?? ''} ${contact.familyName ?? ''}`.trim(),
|
|
167
|
+
phones: collect(contact.phoneNumbers, (v) => String(v.stringValue)),
|
|
168
|
+
emails: collect(contact.emailAddresses, (v) => String(v)),
|
|
169
|
+
});
|
|
170
|
+
},
|
|
171
|
+
contactPickerDidCancel(picker: any) {
|
|
172
|
+
picker.dismissViewControllerAnimatedCompletion(true, null);
|
|
173
|
+
resolve({ picked: false });
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
{ protocols: [CNContactPickerDelegate] }
|
|
177
|
+
);
|
|
178
|
+
const picker = CNContactPickerViewController.new();
|
|
179
|
+
const delegate = DelegateClass.new();
|
|
180
|
+
(picker as any)._appwrapDelegate = delegate; // retain
|
|
181
|
+
picker.delegate = delegate;
|
|
182
|
+
Utils.ios.getRootViewController().presentViewControllerAnimatedCompletion(picker, true, null);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// ── calendar (EventKit) ────────────────────────────────────────────
|
|
188
|
+
bridge.register('calendar.createEvent', ({ title, start, durationMin, notes }: any) => {
|
|
189
|
+
if (!isIOS) throw iosOnly();
|
|
190
|
+
const store = EKEventStore.new();
|
|
191
|
+
return new Promise((resolve, reject) => {
|
|
192
|
+
const onAccess = (granted: boolean, error: NSError | null) => {
|
|
193
|
+
if (!granted) {
|
|
194
|
+
return reject(err('DENIED', error?.localizedDescription ?? 'calendar access denied'));
|
|
195
|
+
}
|
|
196
|
+
Utils.dispatchToMainThread(() => {
|
|
197
|
+
try {
|
|
198
|
+
const event = EKEvent.eventWithEventStore(store);
|
|
199
|
+
event.title = String(title ?? 'Event');
|
|
200
|
+
const startDate = start
|
|
201
|
+
? NSDate.dateWithTimeIntervalSince1970(Date.parse(start) / 1000)
|
|
202
|
+
: NSDate.dateWithTimeIntervalSinceNow(3600);
|
|
203
|
+
event.startDate = startDate;
|
|
204
|
+
event.endDate = startDate.dateByAddingTimeInterval((durationMin ?? 60) * 60);
|
|
205
|
+
if (notes) event.notes = String(notes);
|
|
206
|
+
event.calendar = store.defaultCalendarForNewEvents;
|
|
207
|
+
store.saveEventSpanError(event, EKSpan.ThisEvent, null as any);
|
|
208
|
+
resolve({ id: String(event.eventIdentifier) });
|
|
209
|
+
} catch (e: any) {
|
|
210
|
+
reject(err('NATIVE_ERROR', e.message));
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
};
|
|
214
|
+
// iOS 17 renamed the request API; fall back for older versions
|
|
215
|
+
if (store.requestFullAccessToEventsWithCompletion) {
|
|
216
|
+
store.requestFullAccessToEventsWithCompletion(onAccess);
|
|
217
|
+
} else {
|
|
218
|
+
store.requestAccessToEntityTypeCompletion(EKEntityType.Event, onAccess);
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// ── camera capture (UIImagePickerController) ───────────────────────
|
|
224
|
+
bridge.register('camera.capture', ({ dataUrl, maxSize }: any = {}) => {
|
|
225
|
+
if (!isIOS) throw iosOnly();
|
|
226
|
+
if (!UIImagePickerController.isSourceTypeAvailable(UIImagePickerControllerSourceType.Camera)) {
|
|
227
|
+
throw err('UNSUPPORTED', 'No camera on this device (simulator?)');
|
|
228
|
+
}
|
|
229
|
+
return new Promise((resolve) => {
|
|
230
|
+
Utils.dispatchToMainThread(() => {
|
|
231
|
+
const DelegateClass = (NSObject as any).extend(
|
|
232
|
+
{
|
|
233
|
+
imagePickerControllerDidFinishPickingMediaWithInfo(picker: any, info: NSDictionary<string, any>) {
|
|
234
|
+
picker.dismissViewControllerAnimatedCompletion(true, null);
|
|
235
|
+
const img = info.objectForKey(UIImagePickerControllerOriginalImage) as UIImage | null;
|
|
236
|
+
if (!img) return resolve({ picked: true });
|
|
237
|
+
const out: any = { picked: true, width: img.size.width, height: img.size.height };
|
|
238
|
+
if (dataUrl) out.dataUrl = uiImageToDataUrl(img, maxSize ?? 1024);
|
|
239
|
+
resolve(out);
|
|
240
|
+
},
|
|
241
|
+
imagePickerControllerDidCancel(picker: any) {
|
|
242
|
+
picker.dismissViewControllerAnimatedCompletion(true, null);
|
|
243
|
+
resolve({ picked: false });
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
{ protocols: [UIImagePickerControllerDelegate, UINavigationControllerDelegate] }
|
|
247
|
+
);
|
|
248
|
+
const picker = UIImagePickerController.new();
|
|
249
|
+
picker.sourceType = UIImagePickerControllerSourceType.Camera;
|
|
250
|
+
const delegate = DelegateClass.new();
|
|
251
|
+
(picker as any)._appwrapDelegate = delegate; // retain
|
|
252
|
+
picker.delegate = delegate;
|
|
253
|
+
Utils.ios.getRootViewController().presentViewControllerAnimatedCompletion(picker, true, null);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { Application, Utils, isAndroid, isIOS } from '@nativescript/core';
|
|
2
|
+
import { bridge } from './bridge';
|
|
3
|
+
import { SHELL_CONFIG } from './config';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Remote push (APNs/FCM) — DEVICE SIDE. Acquires the token + surfaces incoming
|
|
7
|
+
* messages/taps; the SEND backend is the consumer's (kit.push returns a raw token).
|
|
8
|
+
*
|
|
9
|
+
* iOS needs zero extra deps (UIApplication/UNUserNotificationCenter are baseline) — only the
|
|
10
|
+
* `aps-environment` entitlement, which the CLI stamps when `appwrap.json.push` is enabled (gated
|
|
11
|
+
* so it never breaks a personal-team build that can't hold the push entitlement).
|
|
12
|
+
*
|
|
13
|
+
* Android token/permission use Firebase Messaging classes guarded at runtime — present only when
|
|
14
|
+
* the FCM plugin + google-services.json are wired (CLI, gated). Incoming Android delivery
|
|
15
|
+
* (onMessage/onTap) needs a FirebaseMessagingService → lands with the messaging plugin (1b).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// ── bridge the async iOS AppDelegate APNs callbacks back to a pending push.register() ──
|
|
19
|
+
let pendingRegister: { resolve: (t: PushToken) => void; reject: (e: Error) => void } | null = null;
|
|
20
|
+
let cachedToken: string | null = null;
|
|
21
|
+
|
|
22
|
+
interface PushToken { platform: 'apns' | 'fcm'; token: string; }
|
|
23
|
+
|
|
24
|
+
/** iOS AppDelegate → APNs device token arrived. Resolve any pending register() + cache. */
|
|
25
|
+
export function onApnsToken(hexToken: string): void {
|
|
26
|
+
cachedToken = hexToken;
|
|
27
|
+
if (pendingRegister) {
|
|
28
|
+
pendingRegister.resolve({ platform: 'apns', token: hexToken });
|
|
29
|
+
pendingRegister = null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** iOS AppDelegate → APNs registration failed. */
|
|
34
|
+
export function onApnsError(message: string): void {
|
|
35
|
+
if (pendingRegister) {
|
|
36
|
+
pendingRegister.reject(Object.assign(new Error(message), { code: 'NATIVE_ERROR' }));
|
|
37
|
+
pendingRegister = null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** iOS AppDelegate / UN delegate → a remote notification arrived (tapped = user opened it). */
|
|
42
|
+
export function onRemoteMessage(userInfo: any, tapped: boolean): void {
|
|
43
|
+
bridge.emit(tapped ? 'push.tap' : 'push.message', parseApnsPayload(userInfo));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Android FirebaseMessagingService (fcm-service.android.ts) → a message arrived while the app
|
|
47
|
+
* process is alive. Forwarded as push.message (the foreground/data path; tray-notification taps come
|
|
48
|
+
* via events.ts → push.tap). */
|
|
49
|
+
export function onFcmMessage(data: Record<string, string>, title?: string, body?: string): void {
|
|
50
|
+
bridge.emit('push.message', { data, title, body });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** APNs userInfo (NSDictionary or auto-marshalled object) → { data, title?, body? }. */
|
|
54
|
+
function parseApnsPayload(userInfo: any): { data: Record<string, unknown>; title?: string; body?: string } {
|
|
55
|
+
const data: Record<string, unknown> = {};
|
|
56
|
+
let title: string | undefined;
|
|
57
|
+
let body: string | undefined;
|
|
58
|
+
const get = (o: any, k: string) => (o && typeof o.objectForKey === 'function' ? o.objectForKey(k) : o?.[k]);
|
|
59
|
+
try {
|
|
60
|
+
const aps = get(userInfo, 'aps');
|
|
61
|
+
const alert = get(aps, 'alert');
|
|
62
|
+
if (alert) {
|
|
63
|
+
if (typeof alert === 'string') body = alert;
|
|
64
|
+
else { title = get(alert, 'title'); body = get(alert, 'body'); }
|
|
65
|
+
}
|
|
66
|
+
// Custom (non-aps) top-level keys → data.
|
|
67
|
+
const keys = userInfo?.allKeys;
|
|
68
|
+
if (keys?.count != null) {
|
|
69
|
+
for (let i = 0; i < keys.count; i++) {
|
|
70
|
+
const k = String(keys.objectAtIndex(i));
|
|
71
|
+
if (k !== 'aps') data[k] = String(userInfo.objectForKey(k));
|
|
72
|
+
}
|
|
73
|
+
} else if (userInfo && typeof userInfo === 'object') {
|
|
74
|
+
for (const k of Object.keys(userInfo)) if (k !== 'aps') data[k] = (userInfo as any)[k];
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
/* best-effort parse — never throw on an inbound message */
|
|
78
|
+
}
|
|
79
|
+
return { data, title: title ? String(title) : undefined, body: body ? String(body) : undefined };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function registerPushHandlers(): void {
|
|
83
|
+
bridge.register('push.requestPermission', () => {
|
|
84
|
+
if (isIOS) {
|
|
85
|
+
return new Promise<string>((resolve) => {
|
|
86
|
+
UNUserNotificationCenter.currentNotificationCenter().requestAuthorizationWithOptionsCompletionHandler(
|
|
87
|
+
UNAuthorizationOptions.Alert | UNAuthorizationOptions.Badge | UNAuthorizationOptions.Sound,
|
|
88
|
+
(granted) => resolve(granted ? 'granted' : 'denied')
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
if (isAndroid) return androidRequestPermission();
|
|
93
|
+
throw unsupported();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
bridge.register('push.register', () => {
|
|
97
|
+
if (isIOS) {
|
|
98
|
+
return new Promise<PushToken>((resolve, reject) => {
|
|
99
|
+
if (cachedToken) return resolve({ platform: 'apns', token: cachedToken });
|
|
100
|
+
pendingRegister = { resolve, reject };
|
|
101
|
+
Utils.dispatchToMainThread(() => UIApplication.sharedApplication.registerForRemoteNotifications());
|
|
102
|
+
setTimeout(() => {
|
|
103
|
+
if (pendingRegister) {
|
|
104
|
+
pendingRegister.reject(Object.assign(new Error('APNs registration timed out'), { code: 'TIMEOUT' }));
|
|
105
|
+
pendingRegister = null;
|
|
106
|
+
}
|
|
107
|
+
}, 25_000);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
if (isAndroid) return androidGetToken();
|
|
111
|
+
throw unsupported();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
bridge.register('push.unregister', () => {
|
|
115
|
+
if (isIOS) {
|
|
116
|
+
Utils.dispatchToMainThread(() => UIApplication.sharedApplication.unregisterForRemoteNotifications());
|
|
117
|
+
cachedToken = null;
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (isAndroid) return androidDeleteToken();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Debug: auto-acquire + log the FCM token at boot so it's readable HEADLESSLY (adb logcat / appwrap
|
|
124
|
+
// logs) without a UI tap — the dev loop needs the token to send a test push. Android only (iOS push
|
|
125
|
+
// is off on personal teams); no-op when push isn't configured for this platform.
|
|
126
|
+
if (SHELL_CONFIG.debug && isAndroid && SHELL_CONFIG.pushAndroid) {
|
|
127
|
+
androidGetToken()
|
|
128
|
+
.then((t) => console.log('[push] FCM token: ' + t.token))
|
|
129
|
+
.catch((e) => console.log('[push] FCM token error: ' + (e?.message || e)));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function unsupported(): Error {
|
|
134
|
+
return Object.assign(new Error('push unsupported on this platform'), { code: 'UNSUPPORTED' });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── Android (Firebase Messaging) — guarded; classes exist only when FCM is wired ──
|
|
138
|
+
function fcm(): any {
|
|
139
|
+
const FM = (global as any).com?.google?.firebase?.messaging?.FirebaseMessaging;
|
|
140
|
+
if (!FM) throw Object.assign(new Error('FCM not configured — wire google-services.json + the messaging plugin'), { code: 'UNSUPPORTED' });
|
|
141
|
+
return FM.getInstance();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function androidRequestPermission(): Promise<string> {
|
|
145
|
+
// Android 13+ (API 33) gates notifications behind POST_NOTIFICATIONS; below that it's implicit.
|
|
146
|
+
return new Promise((resolve) => {
|
|
147
|
+
try {
|
|
148
|
+
const ctx = Utils.android.getApplicationContext();
|
|
149
|
+
const sdk = android.os.Build.VERSION.SDK_INT;
|
|
150
|
+
if (sdk < 33) return resolve('granted');
|
|
151
|
+
const pm = android.content.pm.PackageManager;
|
|
152
|
+
const granted = ctx.checkSelfPermission('android.permission.POST_NOTIFICATIONS') === pm.PERMISSION_GRANTED;
|
|
153
|
+
if (granted) return resolve('granted');
|
|
154
|
+
const activity = Application.android.foregroundActivity || Application.android.startActivity;
|
|
155
|
+
Application.android.on(Application.android.activityRequestPermissionsEvent as any, (args: any) => {
|
|
156
|
+
if (args.requestCode !== 7613) return;
|
|
157
|
+
const ok = args.grantResults?.length && args.grantResults[0] === pm.PERMISSION_GRANTED;
|
|
158
|
+
resolve(ok ? 'granted' : 'denied');
|
|
159
|
+
});
|
|
160
|
+
activity.requestPermissions(['android.permission.POST_NOTIFICATIONS'], 7613);
|
|
161
|
+
} catch (e: any) {
|
|
162
|
+
resolve('denied');
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function androidGetToken(): Promise<PushToken> {
|
|
168
|
+
return new Promise((resolve, reject) => {
|
|
169
|
+
try {
|
|
170
|
+
const OnCompleteListener = (global as any).com.google.android.gms.tasks.OnCompleteListener;
|
|
171
|
+
const task = fcm().getToken();
|
|
172
|
+
task.addOnCompleteListener(new OnCompleteListener({
|
|
173
|
+
onComplete(t: any) {
|
|
174
|
+
if (t.isSuccessful()) resolve({ platform: 'fcm', token: String(t.getResult()) });
|
|
175
|
+
else reject(Object.assign(new Error('FCM token fetch failed'), { code: 'NATIVE_ERROR' }));
|
|
176
|
+
},
|
|
177
|
+
}));
|
|
178
|
+
} catch (e: any) {
|
|
179
|
+
reject(e);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function androidDeleteToken(): Promise<void> {
|
|
185
|
+
return new Promise((resolve) => {
|
|
186
|
+
try { fcm().deleteToken(); } catch { /* best-effort */ }
|
|
187
|
+
resolve();
|
|
188
|
+
});
|
|
189
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Application, Color, Utils, isAndroid, isIOS } from '@nativescript/core';
|
|
2
|
+
import { bridge } from './bridge';
|
|
3
|
+
|
|
4
|
+
declare const android: any, androidx: any;
|
|
5
|
+
|
|
6
|
+
const err = (code: string, message: string) => Object.assign(new Error(message), { code });
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* System / navigation handlers shared by both platforms (each method branches
|
|
10
|
+
* internally rather than relying on the iOS-first + Android-override split):
|
|
11
|
+
* app.openUrl — hand a URL to the OS default handler (leaves the app)
|
|
12
|
+
* app.openSettings — open this app's page in the OS Settings app
|
|
13
|
+
* browser.open — in-app browser (SFSafariViewController / Chrome Custom Tabs)
|
|
14
|
+
*/
|
|
15
|
+
export function registerSystemHandlers(): void {
|
|
16
|
+
bridge.register('app.openUrl', ({ url }: { url: string }) => {
|
|
17
|
+
const target = String(url ?? '');
|
|
18
|
+
if (!target) throw err('NATIVE_ERROR', 'app.openUrl: empty url');
|
|
19
|
+
if (isIOS) {
|
|
20
|
+
Utils.dispatchToMainThread(() => {
|
|
21
|
+
const nsUrl = NSURL.URLWithString(target);
|
|
22
|
+
UIApplication.sharedApplication.openURLOptionsCompletionHandler(nsUrl, NSDictionary.new() as any, null);
|
|
23
|
+
});
|
|
24
|
+
} else if (isAndroid) {
|
|
25
|
+
const intent = new android.content.Intent(
|
|
26
|
+
android.content.Intent.ACTION_VIEW,
|
|
27
|
+
android.net.Uri.parse(target)
|
|
28
|
+
);
|
|
29
|
+
intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK);
|
|
30
|
+
Utils.android.getApplicationContext().startActivity(intent);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
bridge.register('app.openSettings', () => {
|
|
35
|
+
if (isIOS) {
|
|
36
|
+
Utils.dispatchToMainThread(() => {
|
|
37
|
+
const nsUrl = NSURL.URLWithString(UIApplicationOpenSettingsURLString);
|
|
38
|
+
UIApplication.sharedApplication.openURLOptionsCompletionHandler(nsUrl, NSDictionary.new() as any, null);
|
|
39
|
+
});
|
|
40
|
+
} else if (isAndroid) {
|
|
41
|
+
const ctx = Utils.android.getApplicationContext();
|
|
42
|
+
const intent = new android.content.Intent(
|
|
43
|
+
android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
|
44
|
+
android.net.Uri.fromParts('package', ctx.getPackageName(), null)
|
|
45
|
+
);
|
|
46
|
+
intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK);
|
|
47
|
+
ctx.startActivity(intent);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
bridge.register('browser.open', ({ url, toolbarColor }: { url: string; toolbarColor?: string }) => {
|
|
52
|
+
const target = String(url ?? '');
|
|
53
|
+
if (!target) throw err('NATIVE_ERROR', 'browser.open: empty url');
|
|
54
|
+
if (isIOS) {
|
|
55
|
+
Utils.dispatchToMainThread(() => {
|
|
56
|
+
const vc = SFSafariViewController.alloc().initWithURL(NSURL.URLWithString(target));
|
|
57
|
+
if (toolbarColor) vc.preferredControlTintColor = new Color(String(toolbarColor)).ios;
|
|
58
|
+
Utils.ios.getRootViewController().presentViewControllerAnimatedCompletion(vc, true, null);
|
|
59
|
+
});
|
|
60
|
+
} else if (isAndroid) {
|
|
61
|
+
Utils.dispatchToMainThread(() => {
|
|
62
|
+
const builder = new androidx.browser.customtabs.CustomTabsIntent.Builder();
|
|
63
|
+
if (toolbarColor) builder.setToolbarColor(new Color(String(toolbarColor)).android);
|
|
64
|
+
const tabs = builder.build();
|
|
65
|
+
const activity = Application.android.foregroundActivity ?? Application.android.startActivity;
|
|
66
|
+
tabs.launchUrl(activity, android.net.Uri.parse(target));
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
}
|