@livx.cc/appwrap 0.61.9 → 0.61.11
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/package.json +1 -1
- package/runtime/app/shell/capabilities.manifest.ts +4 -2
- package/runtime/app/shell/events.ts +13 -5
- package/runtime/app/shell/handlers-android.ts +35 -0
- package/runtime/app/shell/handlers-push.ts +10 -2
- package/runtime/app/shell/handlers.ts +51 -6
- package/runtime/app/shell/share-outcome.ts +21 -0
- package/runtime/app/shell/toast.ts +4 -1
- package/runtime/tests/bridge-response-delivery.test.ts +1 -0
- package/runtime/tests/env-scheme.test.ts +1 -0
- package/runtime/tests/share-outcome.test.ts +17 -0
package/package.json
CHANGED
|
@@ -83,9 +83,11 @@ export const MODULES: ModuleManifest[] = [
|
|
|
83
83
|
// `shareFiles` hands a file to the OS sheet, and one of the activities the sheet offers for a
|
|
84
84
|
// photo or video is "Save Image"/"Save Video" — which WRITES to the camera roll. iOS omits that
|
|
85
85
|
// activity SILENTLY when the app carries no add-only photo-library usage string, so the sheet
|
|
86
|
-
// appears correct and simply lacks the one row the person was reaching for.
|
|
86
|
+
// appears correct and simply lacks the one row the person was reaching for. `saveToPhotos` writes
|
|
87
|
+
// straight to the library under that SAME add-only string — why it lives here and not in `photos`
|
|
88
|
+
// (which is opt-in and asks for READ access).
|
|
87
89
|
{
|
|
88
|
-
name: 'share', core: true, group: 'core', capabilities: { share: 'native', shareFiles: 'native' },
|
|
90
|
+
name: 'share', core: true, group: 'core', capabilities: { share: 'native', shareFiles: 'native', saveToPhotos: 'native' },
|
|
89
91
|
ios: { permissions: [{ key: 'NSPhotoLibraryAddUsageDescription', domain: 'photosAdd', defaultUsage: 'Save a photo or video you chose to your library.' }] },
|
|
90
92
|
},
|
|
91
93
|
{ name: 'storage', core: true, group: 'core', capabilities: { storage: 'native', secureStorage: 'native' } },
|
|
@@ -4,12 +4,15 @@ import { bridge } from './bridge';
|
|
|
4
4
|
import { connectivityStatus } from './handlers-extended';
|
|
5
5
|
import { isEnvDeepLink, handleEnvDeepLink } from './env-switcher';
|
|
6
6
|
|
|
7
|
+
/** A notification-tap payload as the PWA receives it (`parseApnsPayload` / the FCM intent extras). */
|
|
8
|
+
export type PushTapPayload = { data: Record<string, unknown>; title?: string; body?: string };
|
|
9
|
+
|
|
7
10
|
let pendingDeepLink: string | null = null;
|
|
8
11
|
// A cold-launch env-switch deep link (`<scheme>://env?url=…`). Buffered like `pendingDeepLink` until the
|
|
9
12
|
// PWA handshake, so the confirm dialog + WebView reload run when the shell is actually ready (not during
|
|
10
13
|
// iOS didFinishLaunching / the Android launch-intent read, when no WebView exists yet).
|
|
11
14
|
let pendingEnvDeepLink: string | null = null;
|
|
12
|
-
let pendingPushTap:
|
|
15
|
+
let pendingPushTap: PushTapPayload | null = null;
|
|
13
16
|
let pendingShortcut: string | null = null;
|
|
14
17
|
// True only once the PWA's JS has handshaked — i.e. the WebView is actually
|
|
15
18
|
// running our bundle and about to subscribe. Native page-load is too early:
|
|
@@ -81,10 +84,15 @@ export function onShortcut(id: string): void {
|
|
|
81
84
|
else pendingShortcut = id;
|
|
82
85
|
}
|
|
83
86
|
|
|
84
|
-
/**
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
|
|
87
|
+
/** A tray notification was tapped → the app was re-launched (or foregrounded) with the payload.
|
|
88
|
+
* Buffered until the handshake like deep links, because a cold start from a notification fires this
|
|
89
|
+
* before the WebView is running our bundle — emit it raw and the route is lost, the PWA boots at `/`
|
|
90
|
+
* and an authed user lands on the app's home list instead of the conversation.
|
|
91
|
+
*
|
|
92
|
+
* BOTH platforms route through here: Android from the launch intent, iOS from the
|
|
93
|
+
* UNUserNotificationCenter delegate (handlers-push `onRemoteMessage`, tapped=true). iOS used to
|
|
94
|
+
* `bridge.emit` directly and so had no cold-start buffering at all. */
|
|
95
|
+
export function onPushTap(payload: PushTapPayload): void {
|
|
88
96
|
if (pwaReady) bridge.emit('push.tap', payload);
|
|
89
97
|
else pendingPushTap = payload;
|
|
90
98
|
}
|
|
@@ -2,6 +2,7 @@ import { Application, Utils } from '@nativescript/core';
|
|
|
2
2
|
import { bridge } from './bridge';
|
|
3
3
|
import { requestPermissions, startActivityForResult, uriToDataUrl, bitmapToDataUrl } from './android-helpers';
|
|
4
4
|
import { notifIdentity, notifActions } from './notif-identity';
|
|
5
|
+
import { photoAssetKind } from './share-outcome';
|
|
5
6
|
|
|
6
7
|
// no NS types: android-32 typings omit ContactsContract/MediaStore column + ACTION_PICK_IMAGES constants this file reads
|
|
7
8
|
declare const android: any, androidx: any;
|
|
@@ -585,6 +586,40 @@ export function registerAndroidHandlers(): void {
|
|
|
585
586
|
ctx.startActivity(chooser);
|
|
586
587
|
});
|
|
587
588
|
|
|
589
|
+
// ── save to Photos (MediaStore insert into Pictures/ or Movies/) ─────
|
|
590
|
+
// API 29+ scoped storage needs NO permission to add media the app creates. Older devices would need
|
|
591
|
+
// WRITE_EXTERNAL_STORAGE, which the template does not request — reported as unsupported, not faked.
|
|
592
|
+
bridge.register('share.saveToPhotos', ({ name, mimeType, base64 }: { name?: string; mimeType?: string; base64: string }) => {
|
|
593
|
+
const kind = photoAssetKind(mimeType, name);
|
|
594
|
+
if (!kind) return { saved: false, reason: 'unsupported', message: 'only photos and videos can be saved to Photos' };
|
|
595
|
+
if (android.os.Build.VERSION.SDK_INT < 29) return { saved: false, reason: 'unsupported', message: 'needs Android 10 or later' };
|
|
596
|
+
const MS = android.provider.MediaStore;
|
|
597
|
+
const resolver = context().getContentResolver();
|
|
598
|
+
const collection = kind === 'video'
|
|
599
|
+
? MS.Video.Media.getContentUri(MS.VOLUME_EXTERNAL_PRIMARY)
|
|
600
|
+
: MS.Images.Media.getContentUri(MS.VOLUME_EXTERNAL_PRIMARY);
|
|
601
|
+
const values = new android.content.ContentValues();
|
|
602
|
+
values.put('_display_name', String(name || (kind === 'video' ? 'video.mp4' : 'photo.jpg')));
|
|
603
|
+
if (mimeType) values.put('mime_type', String(mimeType));
|
|
604
|
+
values.put('relative_path', kind === 'video' ? 'Movies' : 'Pictures');
|
|
605
|
+
values.put('is_pending', new java.lang.Integer(1));
|
|
606
|
+
let uri: any = null;
|
|
607
|
+
try {
|
|
608
|
+
uri = resolver.insert(collection, values);
|
|
609
|
+
if (!uri) return { saved: false, reason: 'failed', message: 'MediaStore refused the file' };
|
|
610
|
+
const out = resolver.openOutputStream(uri);
|
|
611
|
+
out.write(android.util.Base64.decode(base64 ?? '', android.util.Base64.DEFAULT));
|
|
612
|
+
out.close();
|
|
613
|
+
const done = new android.content.ContentValues();
|
|
614
|
+
done.put('is_pending', new java.lang.Integer(0));
|
|
615
|
+
resolver.update(uri, done, null, null);
|
|
616
|
+
return { saved: true };
|
|
617
|
+
} catch (e) {
|
|
618
|
+
if (uri) try { resolver.delete(uri, null, null); } catch { /* best-effort cleanup */ }
|
|
619
|
+
return { saved: false, reason: 'failed', message: String((e as Error)?.message ?? e) };
|
|
620
|
+
}
|
|
621
|
+
});
|
|
622
|
+
|
|
588
623
|
// ── screen orientation (Activity.setRequestedOrientation) ──────────
|
|
589
624
|
const SO = android.content.pm.ActivityInfo;
|
|
590
625
|
const requestedOrientation = (o: string): number => {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Application, Http, Utils, isAndroid, isIOS } from '@nativescript/core';
|
|
2
2
|
import { bridge } from './bridge';
|
|
3
3
|
import { SHELL_CONFIG } from './config';
|
|
4
|
+
import { onPushTap } from './events';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Remote push (APNs/FCM) — DEVICE SIDE. Acquires the token + surfaces incoming
|
|
@@ -75,9 +76,16 @@ export function onApnsError(message: string): void {
|
|
|
75
76
|
}
|
|
76
77
|
}
|
|
77
78
|
|
|
78
|
-
/** iOS AppDelegate / UN delegate → a remote notification arrived (tapped = user opened it).
|
|
79
|
+
/** iOS AppDelegate / UN delegate → a remote notification arrived (tapped = user opened it).
|
|
80
|
+
*
|
|
81
|
+
* A TAP goes through `onPushTap` rather than `bridge.emit` so it is buffered until the PWA
|
|
82
|
+
* handshake. A tap that LAUNCHES the app fires from the UN delegate while the WebView has not yet
|
|
83
|
+
* loaded our bundle, so a raw emit reaches no listener and the route is dropped — the app opens on
|
|
84
|
+
* its home screen instead of the notification's target. Android already went through that gate. */
|
|
79
85
|
export function onRemoteMessage(userInfo: any, tapped: boolean): void { // any: APNs userInfo is NSDictionary or auto-marshalled JS object (dual-path)
|
|
80
|
-
|
|
86
|
+
const payload = parseApnsPayload(userInfo);
|
|
87
|
+
if (tapped) onPushTap(payload);
|
|
88
|
+
else bridge.emit('push.message', payload);
|
|
81
89
|
}
|
|
82
90
|
|
|
83
91
|
/** Android FirebaseMessagingService (fcm-service.android.ts) → a message arrived while the app
|
|
@@ -8,10 +8,14 @@ import { setStatusBarStyle } from './status-bar';
|
|
|
8
8
|
import { buildCapabilityMap } from './capabilities.manifest';
|
|
9
9
|
import { ACTIVE_MODULE_NAMES, PACK_MODULES } from './active-modules.generated';
|
|
10
10
|
import { consumePendingBackgroundTaskId } from './background-context';
|
|
11
|
-
import { settleShare, type ShareOutcome } from './share-outcome';
|
|
11
|
+
import { settleShare, photoAssetKind, type ShareOutcome, type SaveToPhotosOutcome } from './share-outcome';
|
|
12
|
+
import { appwrapNativeLog } from './native-log';
|
|
13
|
+
|
|
14
|
+
/** Debug-only native diagnostic line → Documents/appwrap-web.log (`appwrap logs ios`). */
|
|
15
|
+
const dlog = (line: string) => { if (SHELL_CONFIG.debug) appwrapNativeLog(line); };
|
|
12
16
|
|
|
13
17
|
/** Build identifier for the native shell bundle — bump per deploy to spot stale bundles. */
|
|
14
|
-
export const SHELL_BUILD = '
|
|
18
|
+
export const SHELL_BUILD = 'save-to-photos-1';
|
|
15
19
|
|
|
16
20
|
/** Version status the web side (native-kit `kit.updates`) reports via `app.reportWebVersion`. */
|
|
17
21
|
export interface WebVersionInfo { current?: string; latest?: string; build?: string | number; updateAvailable?: boolean; }
|
|
@@ -36,10 +40,12 @@ function presentShareSheet(items: NSMutableArray<any>): Promise<ShareOutcome> {
|
|
|
36
40
|
const finish = (o: ShareOutcome | null) => {
|
|
37
41
|
if (done || !o) return;
|
|
38
42
|
done = true;
|
|
43
|
+
dlog(`[native:share] resolve ${JSON.stringify(o)}`);
|
|
39
44
|
resolve(o);
|
|
40
45
|
};
|
|
41
|
-
const presented = () => !!controller.presentingViewController && !controller.
|
|
46
|
+
const presented = () => !!controller.presentingViewController && !controller.beingDismissed;
|
|
42
47
|
controller.completionWithItemsHandler = (activityType: string, completed: boolean) => {
|
|
48
|
+
dlog(`[native:share] completion activity=${activityType} completed=${!!completed} presented=${presented()}`);
|
|
43
49
|
// Recover the WebView on dismiss — a presented sheet can orphan a touch-stealing window / leave
|
|
44
50
|
// the renderer throttled (see CustomWebView.recoverAfterNativeSurface).
|
|
45
51
|
bridge.getWebView()?.recoverAfterNativeSurface();
|
|
@@ -152,6 +158,44 @@ export function registerHandlers(): void {
|
|
|
152
158
|
}
|
|
153
159
|
);
|
|
154
160
|
|
|
161
|
+
// SAVE TO PHOTOS — write straight into the library, no sheet. The "Save" a camera app offers is
|
|
162
|
+
// this, not a share sheet with a Save row among twenty others. Add-only access (the same
|
|
163
|
+
// NSPhotoLibraryAddUsageDescription the share module already stamps), so the person is never asked
|
|
164
|
+
// to expose their library just to receive one clip. Resolves { saved } — a refusal is a value, not
|
|
165
|
+
// a throw, so the caller can say something honest. Android overrides this in handlers-android.
|
|
166
|
+
bridge.register(
|
|
167
|
+
'share.saveToPhotos',
|
|
168
|
+
({ name, mimeType, base64 }: { name?: string; mimeType?: string; base64: string }): Promise<SaveToPhotosOutcome> | SaveToPhotosOutcome => {
|
|
169
|
+
const kind = photoAssetKind(mimeType, name);
|
|
170
|
+
if (!isIOS || !kind) return { saved: false, reason: 'unsupported', message: kind ? 'no photo library here' : 'only photos and videos can be saved to Photos' };
|
|
171
|
+
return new Promise((resolve) => {
|
|
172
|
+
PHPhotoLibrary.requestAuthorizationForAccessLevelHandler(PHAccessLevel.AddOnly, (status) => {
|
|
173
|
+
dlog(`[native:photos] add-only authorization status=${status}`);
|
|
174
|
+
// Authorized = 3, Limited = 4. Limited still permits adding.
|
|
175
|
+
if (status !== 3 && status !== 4) return resolve({ saved: false, reason: 'denied', message: 'Photos access is off for this app' });
|
|
176
|
+
const data = NSData.alloc().initWithBase64EncodedStringOptions(base64 ?? '', 0 as unknown as NSDataBase64DecodingOptions);
|
|
177
|
+
if (!data) return resolve({ saved: false, reason: 'failed', message: 'could not read that file' });
|
|
178
|
+
// A real extension matters: Photos sniffs the container from the file URL.
|
|
179
|
+
const ext = (String(name ?? '').match(/\.[A-Za-z0-9]+$/)?.[0]) ?? (kind === 'video' ? '.mp4' : '.jpg');
|
|
180
|
+
const filePath = NSTemporaryDirectory() + 'save-' + Date.now() + ext;
|
|
181
|
+
if (!data.writeToFileAtomically(filePath, true)) return resolve({ saved: false, reason: 'failed', message: 'could not stage that file' });
|
|
182
|
+
const url = NSURL.fileURLWithPath(filePath);
|
|
183
|
+
PHPhotoLibrary.sharedPhotoLibrary().performChangesCompletionHandler(
|
|
184
|
+
() => {
|
|
185
|
+
PHAssetCreationRequest.creationRequestForAsset().addResourceWithTypeFileURLOptions(
|
|
186
|
+
kind === 'video' ? PHAssetResourceType.Video : PHAssetResourceType.Photo, url, null);
|
|
187
|
+
},
|
|
188
|
+
(ok, error) => {
|
|
189
|
+
NSFileManager.defaultManager.removeItemAtPathError(filePath);
|
|
190
|
+
dlog(`[native:photos] save ${kind} ok=${ok}${error ? ' err=' + error.localizedDescription : ''}`);
|
|
191
|
+
resolve(ok ? { saved: true } : { saved: false, reason: 'failed', message: error?.localizedDescription || 'Photos refused that file' });
|
|
192
|
+
}
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
);
|
|
198
|
+
|
|
155
199
|
bridge.register('storage.get', ({ key }: { key: string }) =>
|
|
156
200
|
JSON.parse(ApplicationSettings.getString(`kit:${key}`, 'null'))
|
|
157
201
|
);
|
|
@@ -162,9 +206,10 @@ export function registerHandlers(): void {
|
|
|
162
206
|
ApplicationSettings.remove(`kit:${key}`)
|
|
163
207
|
);
|
|
164
208
|
|
|
165
|
-
bridge.register('toast.show', ({ message, duration }: { message: string; duration?: 'short' | 'long' }) =>
|
|
166
|
-
|
|
167
|
-
|
|
209
|
+
bridge.register('toast.show', ({ message, duration }: { message: string; duration?: 'short' | 'long' }) => {
|
|
210
|
+
dlog(`[native:toast] show "${message}" presentedOver=${isIOS ? !!Utils.ios.getRootViewController()?.presentedViewController : "n/a"}`);
|
|
211
|
+
return showToast(String(message ?? ''), duration ?? 'short');
|
|
212
|
+
});
|
|
168
213
|
|
|
169
214
|
// Persistent, tappable banner (e.g. the remote-update "tap to reload" prompt). Tap emits
|
|
170
215
|
// `toast.action` { id } back to the web side.
|
|
@@ -19,3 +19,24 @@ export function settleShare(
|
|
|
19
19
|
if (completed) return activityType ? { completed: true, activity: String(activityType) } : { completed: true };
|
|
20
20
|
return sheetStillPresented ? null : { completed: false };
|
|
21
21
|
}
|
|
22
|
+
|
|
23
|
+
/** What `share.saveToPhotos` resolves with (native-kit `SaveToPhotosResult`). `saved:false` is never a
|
|
24
|
+
* throw: `reason` says why — 'denied' (the person refused, or Settings forbids it: only they can change
|
|
25
|
+
* it), 'unsupported' (not an image/video, or no handler on this platform), 'failed' (the write itself). */
|
|
26
|
+
export interface SaveToPhotosOutcome {
|
|
27
|
+
saved: boolean;
|
|
28
|
+
reason?: 'denied' | 'unsupported' | 'failed';
|
|
29
|
+
message?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Which photo-library asset a file becomes — by mime type, then by extension. `null` = neither (a
|
|
33
|
+
* library only holds photos and videos; a PDF must go through the share sheet instead). */
|
|
34
|
+
export function photoAssetKind(mimeType?: string, name?: string): 'image' | 'video' | null {
|
|
35
|
+
const m = String(mimeType ?? '').toLowerCase();
|
|
36
|
+
if (m.startsWith('image/')) return 'image';
|
|
37
|
+
if (m.startsWith('video/')) return 'video';
|
|
38
|
+
const ext = String(name ?? '').toLowerCase().split('.').pop() ?? '';
|
|
39
|
+
if (['jpg', 'jpeg', 'png', 'heic', 'heif', 'gif', 'webp'].includes(ext)) return 'image';
|
|
40
|
+
if (['mp4', 'mov', 'm4v'].includes(ext)) return 'video';
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
@@ -41,7 +41,10 @@ function showIOSToast(message: string, durationMs: number): void {
|
|
|
41
41
|
const screenBounds = UIScreen.mainScreen.bounds;
|
|
42
42
|
containerView.center = CGPointMake(screenBounds.size.width / 2, screenBounds.size.height - 120);
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
// Onto the WINDOW, not the root view: a toast that reports how a sheet ended is shown while that
|
|
45
|
+
// sheet may still be presented (or animating out), and the root view sits BELOW every presented
|
|
46
|
+
// controller — the toast would render, underneath, and be gone before the sheet was.
|
|
47
|
+
(rootVC.view.window ?? rootVC.view).addSubview(containerView);
|
|
45
48
|
containerView.alpha = 0;
|
|
46
49
|
containerView.transform = CGAffineTransformMakeScale(0.8, 0.8);
|
|
47
50
|
|
|
@@ -4,6 +4,7 @@ import { beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
|
4
4
|
// Superset shape — bun shares the runtime-module cache across files in a dir run, so every
|
|
5
5
|
// @nativescript/core mock must expose the same named exports the runtime modules import.
|
|
6
6
|
mock.module('@nativescript/core', () => ({
|
|
7
|
+
Http: { request: async () => ({ content: null }) },
|
|
7
8
|
isIOS: false, isAndroid: false, WebView: class {},
|
|
8
9
|
ApplicationSettings: { getString: (_k: string, d = '') => d, setString: () => {}, remove: () => {} },
|
|
9
10
|
Dialogs: { confirm: async () => true, action: async () => '', alert: async () => undefined, prompt: async () => ({ result: false, text: '' }) },
|
|
@@ -10,6 +10,7 @@ import { afterEach, describe, expect, mock, test } from 'bun:test';
|
|
|
10
10
|
// which throws-and-falls-back off-device — safe to stub minimally).
|
|
11
11
|
let androidFlag = false;
|
|
12
12
|
mock.module('@nativescript/core', () => ({
|
|
13
|
+
Http: { request: async () => ({ content: null }) },
|
|
13
14
|
Application: { android: {} },
|
|
14
15
|
get isAndroid() { return androidFlag; },
|
|
15
16
|
isIOS: false,
|
|
@@ -25,3 +25,20 @@ describe('settleShare', () => {
|
|
|
25
25
|
expect(settleShare('com.apple.UIKit.activity.Mail', false, true)).toBeNull();
|
|
26
26
|
});
|
|
27
27
|
});
|
|
28
|
+
|
|
29
|
+
import { photoAssetKind } from '../app/shell/share-outcome';
|
|
30
|
+
|
|
31
|
+
describe('photoAssetKind', () => {
|
|
32
|
+
test('mime type decides first', () => {
|
|
33
|
+
expect(photoAssetKind('video/mp4', 'x.png')).toBe('video');
|
|
34
|
+
expect(photoAssetKind('image/png')).toBe('image');
|
|
35
|
+
});
|
|
36
|
+
test('falls back to the extension when the mime type is generic', () => {
|
|
37
|
+
expect(photoAssetKind('application/octet-stream', 'clip.MOV')).toBe('video');
|
|
38
|
+
expect(photoAssetKind('', 'shot.jpeg')).toBe('image');
|
|
39
|
+
});
|
|
40
|
+
test('anything else is not a photo-library asset', () => {
|
|
41
|
+
expect(photoAssetKind('application/pdf', 'a.pdf')).toBeNull();
|
|
42
|
+
expect(photoAssetKind(undefined, undefined)).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
});
|