@livx.cc/appwrap 0.47.2 → 0.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/package.json +1 -1
  2. package/runtime/App_Resources/Android/src/main/AndroidManifest.xml +3 -0
  3. package/runtime/app/shell/capabilities.manifest.ts +65 -3
  4. package/runtime/app/shell/custom-webview.android.ts +22 -2
  5. package/runtime/app/shell/handlers-android.ts +54 -9
  6. package/runtime/app/shell/handlers-extended.ts +82 -4
  7. package/runtime/app/shell/handlers-system.ts +48 -1
  8. package/runtime/app/shell/handlers-widget.ts +97 -0
  9. package/runtime/app/shell/notif-identity.ts +45 -0
  10. package/runtime/modules-native/widget/App_Resources/Android/src/main/java/cc/livx/appwrap/AppwrapWidgetProvider.kt +79 -0
  11. package/runtime/modules-native/widget/App_Resources/Android/src/main/res/drawable/appwrap_badge_bg.xml +7 -0
  12. package/runtime/modules-native/widget/App_Resources/Android/src/main/res/layout/appwrap_widget.xml +70 -0
  13. package/runtime/modules-native/widget/App_Resources/Android/src/main/res/values/appwrap_widget_styles.xml +40 -0
  14. package/runtime/modules-native/widget/App_Resources/Android/src/main/res/xml/appwrap_widget_info.xml +13 -0
  15. package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/AppwrapWidget.entitlements +10 -0
  16. package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/AppwrapWidget.swift +275 -0
  17. package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/Info.plist +25 -0
  18. package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/extension.json +9 -0
  19. package/runtime/modules-native/widget/App_Resources/iOS/src/AppwrapWidgetReload.swift +18 -0
  20. package/runtime/references.d.ts +1 -0
  21. package/runtime/tests/notif-identity.test.ts +43 -0
  22. package/runtime-desktop/chrome/appwrap-browser-chrome.html +70 -5
  23. package/runtime-desktop/crates/webview-control/src/lib.rs +2 -1
  24. package/runtime-desktop/crates/webview-control/src/wkwebview_backend.rs +22 -9
  25. package/runtime-desktop/src-tauri/src/browser_tab.rs +379 -20
  26. package/runtime-desktop/src-tauri/src/main.rs +191 -11
  27. package/src/cli.ts +74 -0
  28. package/src/config.ts +8 -1
  29. package/src/plugin/host.ts +5 -0
  30. package/src/plugin/types.ts +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livx.cc/appwrap",
3
- "version": "0.47.2",
3
+ "version": "0.48.0",
4
4
  "description": "Wrap any PWA into a native app with native capabilities (appwrap runtime + @livx.cc/native-kit).",
5
5
  "license": "MIT",
6
6
  "author": "Elya Livshitz",
@@ -55,6 +55,9 @@
55
55
  <data android:scheme="appwrap" />
56
56
  <!-- appwrap:oauth-redirect-schemes --><!-- /appwrap:oauth-redirect-schemes -->
57
57
  </intent-filter>
58
+ <!-- App Links (https) — autoVerify intent-filters stamped per host from config.androidAppLinks
59
+ (or derived from iOS associated-domains applinks:). Needs assetlinks.json on the host. -->
60
+ <!-- appwrap:app-links --><!-- /appwrap:app-links -->
58
61
  </activity>
59
62
  <activity android:name="com.tns.ErrorReportActivity"/>
60
63
 
@@ -82,7 +82,7 @@ export const MODULES: ModuleManifest[] = [
82
82
  // springboard badge; Android is an honest no-op (launchers own badges) → ios:true / android:false.
83
83
  // app.canOpenUrl rides the always-native `app` cap (no new key). shortcuts: home-screen quick
84
84
  // actions — iOS UIApplicationShortcutItem / Android dynamic shortcuts (API 25+).
85
- { name: 'app', core: true, group: 'system', capabilities: { app: 'native', browser: 'native', badge: { ios: true, android: false }, shortcuts: { ios: true, android: true } } },
85
+ { name: 'app', core: true, group: 'system', capabilities: { app: 'native', browser: 'native', badge: { ios: true, android: false }, shortcuts: { ios: true, android: true }, pinShortcut: { ios: false, android: true } } },
86
86
 
87
87
  // ── opt-in: permission / dependency / weight-bearing ───────────────────
88
88
  // POST_NOTIFICATIONS / VIBRATE / USE_BIOMETRIC etc. are in the template's baseline manifest already.
@@ -298,13 +298,75 @@ export const MODULES: ModuleManifest[] = [
298
298
  {
299
299
  name: 'backgroundTask', group: 'backgroundTask',
300
300
  capabilities: { backgroundTask: { ios: true, android: true } },
301
- android: { gradleDeps: ['androidx.work:work-runtime:2.9.1'] },
301
+ // work-runtime's Worker extends ListenableWorker, whose methods return guava's ListenableFuture;
302
+ // that type must be on the COMPILE classpath or javac fails with "cannot access ListenableFuture".
303
+ // The tiny listenablefuture:1.0 stub does NOT work here: other androidx deps transitively pull
304
+ // listenablefuture:9999.0-empty-to-avoid-conflict-with-guava (an EMPTY jar), which wins the
305
+ // version/capability conflict and evicts the real class. Full guava provides the listenablefuture
306
+ // capability and substitutes that empty stub, so it's the dep that actually resolves the class.
307
+ android: { gradleDeps: ['androidx.work:work-runtime:2.9.1', 'com.google.guava:guava:33.3.1-android'] },
308
+ },
309
+
310
+ // ── widget — home-screen presence via a native extension (iOS WidgetKit / Android AppWidget) ──
311
+ // Opt-in; ships its own extension target under modules-native/widget/. The host publishes tiles into
312
+ // an App Group container the extension reads at render; each tile deep-links back on tap. The App
313
+ // Group entitlement is app-specific (group.<appId>), so consumers supply it via appwrap.config
314
+ // `iosEntitlements` for now (Phase-2 templates `group.$APPID` in the CLI). Phase-1 spike ships a
315
+ // single static tile to prove the extension builds, provisions the group, renders, and deep-links.
316
+ {
317
+ name: 'widget', group: 'widget',
318
+ capabilities: { widget: { ios: true, android: true } },
319
+ // Android launcher widget = an AppWidgetProvider receiver (icon-grid) fed by the widget.publish
320
+ // handler via SharedPreferences; Kotlin source + res ship as nativeSrc. iOS side = the WidgetKit
321
+ // extension under nativeSrc's App_Resources/iOS/extensions/.
322
+ android: {
323
+ kotlin: true,
324
+ manifestApplication: `<receiver
325
+ android:name="cc.livx.appwrap.AppwrapWidgetProvider"
326
+ android:exported="true">
327
+ <intent-filter>
328
+ <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
329
+ </intent-filter>
330
+ <meta-data
331
+ android:name="android.appwidget.provider"
332
+ android:resource="@xml/appwrap_widget_info" />
333
+ </receiver>`,
334
+ },
335
+ nativeSrc: 'widget',
336
+ },
337
+
338
+ // ── widget — home-screen presence via a native extension (iOS WidgetKit / Android AppWidget) ──
339
+ // Opt-in; ships its own extension target under modules-native/widget/. The host publishes tiles into
340
+ // an App Group container the extension reads at render; each tile deep-links back on tap. The App
341
+ // Group entitlement is app-specific (group.<appId>), so consumers supply it via appwrap.config
342
+ // `iosEntitlements` for now (Phase-2 templates `group.$APPID` in the CLI). Phase-1 spike ships a
343
+ // single static tile to prove the extension builds, provisions the group, renders, and deep-links.
344
+ {
345
+ name: 'widget', group: 'widget',
346
+ capabilities: { widget: { ios: true, android: true } },
347
+ // Android launcher widget = an AppWidgetProvider receiver (icon-grid) fed by the widget.publish
348
+ // handler via SharedPreferences; Kotlin source + res ship as nativeSrc. iOS side = the WidgetKit
349
+ // extension under nativeSrc's App_Resources/iOS/extensions/.
350
+ android: {
351
+ kotlin: true,
352
+ manifestApplication: `<receiver
353
+ android:name="cc.livx.appwrap.AppwrapWidgetProvider"
354
+ android:exported="true">
355
+ <intent-filter>
356
+ <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
357
+ </intent-filter>
358
+ <meta-data
359
+ android:name="android.appwidget.provider"
360
+ android:resource="@xml/appwrap_widget_info" />
361
+ </receiver>`,
362
+ },
363
+ nativeSrc: 'widget',
302
364
  },
303
365
  ];
304
366
 
305
367
  /** Opt-in registration groups that own their own NS handler file (strippable when inactive). Core
306
368
  * groups (core/extended/parity/system/media/billing) are always bundled; only these are CLI-gated. */
307
- export const OPTIONAL_GROUPS = ['health', 'oauth', 'reviews', 'scanner', 'speech', 'tracking', 'appleSignIn', 'backgroundTask'] as const;
369
+ export const OPTIONAL_GROUPS = ['health', 'oauth', 'reviews', 'scanner', 'speech', 'tracking', 'appleSignIn', 'backgroundTask', 'widget'] as const;
308
370
 
309
371
  /** Resolve the active capability map for the handshake from a set of active capability names. */
310
372
  export function buildCapabilityMap(
@@ -196,6 +196,15 @@ export class CustomWebView extends WebView {
196
196
  if (wkt.WebViewFeature.isFeatureSupported(wkt.WebViewFeature.DOCUMENT_START_SCRIPT)) {
197
197
  const rules = new java.util.HashSet<string>();
198
198
  rules.add(APP_ORIGIN);
199
+ // loader:'server' loads a REMOTE origin (serverUrl), not the app:// bundle. The document-start
200
+ // bridge shim must be allowed THERE too — otherwise it only lands via the onPageStarted
201
+ // fallback, which RACES the page's native-kit detect(): it sees no `window.appwrapNative`,
202
+ // falls back to the web adapter, and NO native capability works (share/widget/pin all report
203
+ // 'none'). Adding the server origin injects the bridge before any page script runs.
204
+ const serverOrigin = SHELL_CONFIG.loader === 'server'
205
+ ? (String(SHELL_CONFIG.serverUrl).match(/^https?:\/\/[^/]+/)?.[0] ?? '')
206
+ : '';
207
+ if (serverOrigin) rules.add(serverOrigin);
199
208
  wkt.WebViewCompat.addDocumentStartJavaScript(wv, buildBootstrapJs(), rules);
200
209
  return;
201
210
  }
@@ -351,9 +360,20 @@ function createAssetServingClient(): android.webkit.WebViewClient {
351
360
  ? android.content.Intent.parseUri(url, android.content.Intent.URI_INTENT_SCHEME)
352
361
  : new android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(url));
353
362
  intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK);
354
- view.getContext().startActivity(intent);
363
+ try {
364
+ view.getContext().startActivity(intent);
365
+ } catch (notInstalled) {
366
+ // Target app missing. intent: URLs carry `browser_fallback_url` for exactly this (Chrome
367
+ // semantics — e.g. "add to Google Calendar" falls back to the web calendar). Honor it via the
368
+ // OS browser rather than silently dropping the action.
369
+ const fallback = scheme === 'intent' && intent.getStringExtra('browser_fallback_url');
370
+ if (!fallback) throw notInstalled;
371
+ const fb = new android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(String(fallback)));
372
+ fb.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK);
373
+ view.getContext().startActivity(fb);
374
+ }
355
375
  } catch (e) {
356
- // No installed app handles the scheme — swallow (better a no-op than an error page over the app).
376
+ // No installed app handles the scheme / no fallback — swallow (better a no-op than an error page).
357
377
  console.warn('[appwrap] no OS handler for external URL: ' + url + ' (' + e + ')');
358
378
  }
359
379
  return true;
@@ -1,6 +1,7 @@
1
1
  import { Application, Utils } from '@nativescript/core';
2
2
  import { bridge } from './bridge';
3
3
  import { requestPermissions, startActivityForResult, uriToDataUrl, bitmapToDataUrl } from './android-helpers';
4
+ import { notifIdentity } from './notif-identity';
4
5
 
5
6
  // no NS types: android-32 typings omit ContactsContract/MediaStore column + ACTION_PICK_IMAGES constants this file reads
6
7
  declare const android: any, androidx: any;
@@ -23,12 +24,40 @@ export function registerAndroidHandlers(): void {
23
24
  context().getSystemService(android.content.Context.NOTIFICATION_SERVICE);
24
25
  const pendingTimers = new Map<number, any>();
25
26
 
26
- const ensureChannel = () => {
27
+ const ensureChannel = (channelId: string = CHANNEL_ID, name: string = 'Notifications') => {
27
28
  if (android.os.Build.VERSION.SDK_INT < 26) return;
28
29
  const channel = new android.app.NotificationChannel(
29
- CHANNEL_ID, 'Notifications', android.app.NotificationManager.IMPORTANCE_DEFAULT
30
+ channelId, name, android.app.NotificationManager.IMPORTANCE_DEFAULT
30
31
  );
31
- notificationManager().createNotificationChannel(channel);
32
+ notificationManager().createNotificationChannel(channel); // idempotent; updates name in place
33
+ };
34
+
35
+ // Per-sender channel id — a stable, sanitized key so each mini-app gets its own row
36
+ // in Android's notification settings (real per-"app" mute/importance).
37
+ const senderChannelId = (sender: string) => 'sender-' + sender.replace(/[^a-zA-Z0-9]/g, '_').slice(0, 64);
38
+
39
+ /** Decode an icon (http(s) URL or data-URI) to a Bitmap for setLargeIcon; null on failure.
40
+ * Called on a background thread (URL fetch would throw NetworkOnMainThread otherwise). */
41
+ const loadBitmap = (icon: string): android.graphics.Bitmap | null => {
42
+ try {
43
+ if (icon.startsWith('data:')) {
44
+ const comma = icon.indexOf(',');
45
+ const b64 = comma >= 0 ? icon.slice(comma + 1) : '';
46
+ if (!b64) return null;
47
+ const bytes = android.util.Base64.decode(b64, android.util.Base64.DEFAULT);
48
+ return android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.length);
49
+ }
50
+ const conn = new java.net.URL(icon).openConnection();
51
+ conn.setConnectTimeout(5000);
52
+ conn.setReadTimeout(5000);
53
+ const stream = conn.getInputStream();
54
+ const bmp = android.graphics.BitmapFactory.decodeStream(stream);
55
+ stream.close();
56
+ return bmp;
57
+ } catch (e) {
58
+ console.warn('AppWrap: notification largeIcon load failed', e);
59
+ return null;
60
+ }
32
61
  };
33
62
 
34
63
  bridge.register('notifications.requestPermission', async () => {
@@ -37,20 +66,24 @@ export function registerAndroidHandlers(): void {
37
66
  return ok ? 'granted' : 'denied';
38
67
  });
39
68
 
40
- bridge.register('notifications.schedule', ({ id, title, body, delaySec, deepLink }: any) => {
41
- ensureChannel();
69
+ bridge.register('notifications.schedule', ({ id, title, body, delaySec, deepLink, sender, icon }: any) => {
70
+ const ident = notifIdentity({ title, body, sender, icon });
71
+ // Per-sender channel gives the mini-app its own identity + settings row; else the shared one.
72
+ const channelId = ident.useIdentity && ident.senderName ? senderChannelId(ident.senderName) : CHANNEL_ID;
73
+ ensureChannel(channelId, ident.useIdentity && ident.senderName ? ident.senderName : 'Notifications');
42
74
  const nid = id ?? Math.floor(Math.random() * 100000);
43
75
  const timer = setTimeout(() => {
44
76
  pendingTimers.delete(nid);
45
77
  const ctx = context();
46
78
  const builder = android.os.Build.VERSION.SDK_INT >= 26
47
- ? new android.app.Notification.Builder(ctx, CHANNEL_ID)
79
+ ? new android.app.Notification.Builder(ctx, channelId)
48
80
  : new android.app.Notification.Builder(ctx);
49
81
  builder
50
82
  .setSmallIcon(ctx.getApplicationInfo().icon)
51
- .setContentTitle(String(title ?? ''))
83
+ .setContentTitle(ident.title)
52
84
  .setAutoCancel(true);
53
- if (body) builder.setContentText(String(body));
85
+ if (ident.subtitle) builder.setSubText(ident.subtitle);
86
+ if (ident.body) builder.setContentText(ident.body);
54
87
  // Tap → re-open the (singleTask) activity with a VIEW intent; onNewIntent
55
88
  // routes it through the same deep-link path as an external open.
56
89
  if (deepLink) {
@@ -66,7 +99,19 @@ export function registerAndroidHandlers(): void {
66
99
  : android.app.PendingIntent.FLAG_UPDATE_CURRENT;
67
100
  builder.setContentIntent(android.app.PendingIntent.getActivity(ctx, nid, viewIntent, piFlags));
68
101
  }
69
- notificationManager().notify(nid, builder.build());
102
+ // With an icon: fetch the bitmap off the main thread (URL fetch would crash on it),
103
+ // set it as the large icon, then post. Without: post inline.
104
+ if (ident.iconUrl) {
105
+ new java.lang.Thread(new java.lang.Runnable({
106
+ run: () => {
107
+ const bmp = loadBitmap(ident.iconUrl);
108
+ if (bmp) builder.setLargeIcon(bmp);
109
+ notificationManager().notify(nid, builder.build());
110
+ },
111
+ })).start();
112
+ } else {
113
+ notificationManager().notify(nid, builder.build());
114
+ }
70
115
  }, Math.max(1, delaySec ?? 1) * 1000);
71
116
  pendingTimers.set(nid, timer);
72
117
  return { id: nid };
@@ -3,6 +3,7 @@ import { bridge } from './bridge';
3
3
  import { onDeepLink } from './events';
4
4
  import { onRemoteMessage } from './handlers-push';
5
5
  import { uiImageToDataUrl } from './ios-image';
6
+ import { notifIdentity, type NotifIdentity } from './notif-identity';
6
7
  import { maskForLock, setIosOrientationMask } from './orientation';
7
8
 
8
9
  interface GeoResult { lat: number; lng: number; accuracy: number; }
@@ -212,25 +213,39 @@ export function registerExtendedHandlers(): void {
212
213
  });
213
214
  });
214
215
 
215
- bridge.register('notifications.schedule', ({ id, title, body, delaySec, deepLink }: { id?: number; title?: string; body?: string; delaySec?: number; deepLink?: string }) => {
216
+ bridge.register('notifications.schedule', ({ id, title, body, delaySec, deepLink, sender, icon, badge }: { id?: number; title?: string; body?: string; delaySec?: number; deepLink?: string; sender?: string; icon?: string; badge?: number }) => {
216
217
  if (!isIOS) throw Object.assign(new Error('iOS only for now'), { code: 'UNSUPPORTED' });
217
218
  const nid = id ?? Math.floor(Math.random() * 100000);
219
+ const ident = notifIdentity({ title, body, sender, icon });
218
220
  return new Promise((resolve, reject) => {
219
221
  const content = UNMutableNotificationContent.new();
220
- content.title = String(title ?? '');
221
- if (body) content.body = String(body);
222
+ content.title = ident.title;
223
+ if (ident.subtitle) content.subtitle = ident.subtitle;
224
+ if (ident.body) content.body = ident.body;
225
+ // App-icon badge on delivery (set before the communication-style wrap so it's carried through).
226
+ if (typeof badge === 'number') content.badge = badge;
227
+ // Group per-sender so the mini-app's notifications thread together (iOS 15+ also
228
+ // uses this alongside the communication-style avatar below).
229
+ if (ident.useIdentity && ident.senderName) content.threadIdentifier = ident.senderName;
222
230
  // Carry the deep-link on the notification; the tap delegate re-emits it.
223
231
  // Plain JS object → NativeScript marshals it to NSDictionary (more reliable
224
232
  // than dictionaryWithObjectForKey across NS versions).
225
233
  // cast: NS marshals a plain JS object → NSDictionary at the interop boundary (typed NSDictionary).
226
234
  if (deepLink) content.userInfo = { url: String(deepLink) } as any;
235
+
236
+ // iOS 15+ COMMUNICATION notification: present the mini-app as the sender (name +
237
+ // circular avatar) via an INSendMessageIntent. Degrades to the plain `content`
238
+ // above pre-iOS-15 or when styling declines (e.g. missing communication entitlement).
239
+ const finalContent: UNNotificationContent =
240
+ (ident.useIdentity && communicationContent(content, String(nid), ident)) || content;
241
+
227
242
  const trigger = UNTimeIntervalNotificationTrigger.triggerWithTimeIntervalRepeats(
228
243
  Math.max(1, delaySec ?? 1),
229
244
  false
230
245
  );
231
246
  const request = UNNotificationRequest.requestWithIdentifierContentTrigger(
232
247
  String(nid),
233
- content,
248
+ finalContent,
234
249
  trigger
235
250
  );
236
251
  UNUserNotificationCenter.currentNotificationCenter().addNotificationRequestWithCompletionHandler(
@@ -434,6 +449,69 @@ export function installForegroundNotificationDelegate(): void {
434
449
  UNUserNotificationCenter.currentNotificationCenter().delegate = notifDelegate;
435
450
  }
436
451
 
452
+ /** Load an icon (http(s) URL or data-URI) into NSData for an INImage avatar. Sync
453
+ * fetch — acceptable for a small local-notification icon; returns null on failure. */
454
+ function iconNSData(icon: string): NSData | null {
455
+ try {
456
+ if (icon.startsWith('data:')) {
457
+ const comma = icon.indexOf(',');
458
+ const b64 = comma >= 0 ? icon.slice(comma + 1) : '';
459
+ if (!b64) return null;
460
+ return NSData.alloc().initWithBase64EncodedStringOptions(b64, NSDataBase64DecodingOptions.IgnoreUnknownCharacters);
461
+ }
462
+ const url = NSURL.URLWithString(icon);
463
+ return url ? NSData.dataWithContentsOfURL(url) : null;
464
+ } catch (e) {
465
+ console.warn('[appwrap] notification icon load failed', String(e));
466
+ return null;
467
+ }
468
+ }
469
+
470
+ /**
471
+ * Build an iOS-15+ communication notification: an INSendMessageIntent whose sender IS
472
+ * the mini-app (name + INImage avatar), donated, then `content.updating(from:)` so the
473
+ * banner renders with the sender's identity. Mirrors feedox's NotificationService.swift.
474
+ * Returns null pre-iOS-15 or if any step declines (caller falls back to plain content).
475
+ */
476
+ function communicationContent(content: UNMutableNotificationContent, id: string, ident: NotifIdentity): UNNotificationContent | null {
477
+ // iOS 15.0+ only — INSendMessageIntent conforms to UNNotificationContentProviding there.
478
+ if (!NSProcessInfo.processInfo.isOperatingSystemAtLeastVersion({ majorVersion: 15, minorVersion: 0, patchVersion: 0 })) {
479
+ return null;
480
+ }
481
+ try {
482
+ const displayName = ident.senderName || ident.title;
483
+ const handleValue = ident.senderName || id;
484
+ let image: INImage | null = null;
485
+ if (ident.iconUrl) {
486
+ const data = iconNSData(ident.iconUrl);
487
+ if (data) image = INImage.imageWithImageData(data);
488
+ }
489
+ const handle = INPersonHandle.alloc().initWithValueType(handleValue, INPersonHandleType.Unknown);
490
+ // null for optional ObjC object params (nameComponents/contactIdentifier); interop marshals it to nil.
491
+ const sender = INPerson.alloc().initWithPersonHandleNameComponentsDisplayNameImageContactIdentifierCustomIdentifier(
492
+ handle, null as any, displayName, image as any, null as any, handleValue
493
+ );
494
+ const intent = INSendMessageIntent.alloc().initWithRecipientsOutgoingMessageTypeContentSpeakableGroupNameConversationIdentifierServiceNameSenderAttachments(
495
+ null as any,
496
+ INOutgoingMessageType.OutgoingMessageText,
497
+ content.body,
498
+ INSpeakableString.alloc().initWithSpokenPhrase(displayName),
499
+ handleValue,
500
+ null as any,
501
+ sender,
502
+ null as any
503
+ );
504
+ const interaction = INInteraction.alloc().initWithIntentResponse(intent, null as any);
505
+ interaction.direction = INInteractionDirection.Incoming;
506
+ interaction.donateInteractionWithCompletion(null as any);
507
+ // INSendMessageIntent conforms to UNNotificationContentProviding (iOS 15+).
508
+ return content.contentByUpdatingWithProviderError(intent as unknown as UNNotificationContentProviding, null);
509
+ } catch (e) {
510
+ console.warn('[appwrap] communication notification styling declined', String(e));
511
+ return null;
512
+ }
513
+ }
514
+
437
515
  /** A remote (APNs) notification has a UNPushNotificationTrigger; local ones have a time/calendar trigger. */
438
516
  function isRemotePush(notification: UNNotification): boolean {
439
517
  const trigger = notification?.request?.trigger;
@@ -1,4 +1,4 @@
1
- import { Application, ApplicationSettings, Color, Utils, isAndroid, isIOS } from '@nativescript/core';
1
+ import { Application, ApplicationSettings, Color, ImageSource, Utils, isAndroid, isIOS } from '@nativescript/core';
2
2
  import { bridge } from './bridge';
3
3
 
4
4
  declare const androidx: any; // no NS types: androidx
@@ -201,6 +201,53 @@ export function registerSystemHandlers(): void {
201
201
  }
202
202
  });
203
203
 
204
+ // Pin a STANDALONE launcher icon to the home screen (distinct from setShortcuts' long-press menu).
205
+ // Android 8+ (API 26) ShortcutManager.requestPinShortcut — the system shows the add-to-home-screen
206
+ // prompt. iOS has NO equivalent API (a pinned mini-app icon there is served by the widget instead),
207
+ // so the capability map gates this to android-only and we return {pinned:false} defensively.
208
+ // `url` is the full deep link (e.g. blank://a/<id>) supplied by the caller — keeps this app-agnostic;
209
+ // the tap flows through the app's existing deep-link routing. `iconUrl` is fetched to a bitmap tile.
210
+ bridge.register('app.pinShortcut', async ({ id, title, url, iconUrl, subtitle }: { id: string; title: string; url: string; iconUrl?: string; subtitle?: string }) => {
211
+ if (!id || !title || !url) throw err('NATIVE_ERROR', 'app.pinShortcut: id, title, url required');
212
+ if (!isAndroid) return { pinned: false, reason: 'unsupported' as const };
213
+ if (android.os.Build.VERSION.SDK_INT < 26) return { pinned: false, reason: 'unsupported' as const };
214
+ const ctx = Utils.android.getApplicationContext();
215
+ const sm = ctx.getSystemService(android.content.Context.SHORTCUT_SERVICE);
216
+ if (!sm || !sm.isRequestPinShortcutSupported()) return { pinned: false, reason: 'unsupported' as const };
217
+
218
+ const intent = new android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(String(url)));
219
+ const launchClass = (Application.android.foregroundActivity ?? Application.android.startActivity)?.getClass();
220
+ if (launchClass) intent.setClassName(ctx, launchClass.getName());
221
+ intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK | android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP);
222
+
223
+ const b = new android.content.pm.ShortcutInfo.Builder(ctx, 'pin_' + String(id))
224
+ .setShortLabel(String(title))
225
+ .setLongLabel(String(subtitle ?? title))
226
+ .setIntent(intent);
227
+
228
+ // Tile icon: fetch iconUrl → bitmap. On any failure fall back to the app launcher icon (never
229
+ // block the pin on a network hiccup). ImageSource.fromUrl resolves off the main thread.
230
+ let iconSet = false;
231
+ if (iconUrl) {
232
+ try {
233
+ const img = await ImageSource.fromUrl(String(iconUrl));
234
+ if (img?.android) {
235
+ b.setIcon(android.graphics.drawable.Icon.createWithBitmap(img.android));
236
+ iconSet = true;
237
+ }
238
+ } catch (e) {
239
+ console.log('app.pinShortcut: icon fetch failed, using launcher icon: ' + e);
240
+ }
241
+ }
242
+ if (!iconSet) {
243
+ const resId = ctx.getApplicationInfo().icon;
244
+ if (resId) b.setIcon(android.graphics.drawable.Icon.createWithResource(ctx, resId));
245
+ }
246
+
247
+ sm.requestPinShortcut(b.build(), null);
248
+ return { pinned: true };
249
+ });
250
+
204
251
  // Privacy screen — hide content in the app-switcher / block screenshots. iOS: cover the key window
205
252
  // with a blur while inactive/backgrounded (wired via the app lifecycle below). Android: FLAG_SECURE.
206
253
  bridge.register('screen.setPrivacy', ({ enabled }: { enabled: boolean }) => {
@@ -0,0 +1,97 @@
1
+ import { Application, ImageSource, Utils, isAndroid, isIOS } from '@nativescript/core';
2
+ import { bridge } from './bridge';
3
+
4
+ // WidgetKit's WidgetCenter is Swift-only (no ObjC API) → unreachable from NS directly. The widget
5
+ // module ships an @objc shim (AppwrapWidgetReload) in the app to bridge reloadAllTimelines():
6
+ declare const AppwrapWidgetReload: any;
7
+
8
+ const err = (code: string, message: string) => Object.assign(new Error(message), { code });
9
+
10
+ // Shared-store key per widget kind, so a `data` publish never clobbers the `launcher` grid.
11
+ const payloadKeyFor = (slot?: string) => (slot === 'data' ? 'appwrap.widget.data' : 'appwrap.widget');
12
+ const PREFS_NAME = 'appwrap.widget'; // Android SharedPreferences file (keyed per slot inside)
13
+
14
+ /** iOS App Group id — `group.<bundleId>`, matching what the CLI stamps onto both targets. */
15
+ function iosAppGroup(): string {
16
+ return 'group.' + NSBundle.mainBundle.bundleIdentifier;
17
+ }
18
+
19
+ /** Download an image to a PNG file, returning the saved filename (or null on failure). Reused for
20
+ * iOS (App Group container) and Android (files dir). */
21
+ async function saveIcon(url: string, dir: string, filename: string): Promise<string | null> {
22
+ try {
23
+ const img = await ImageSource.fromUrl(url);
24
+ const dest = dir.replace(/\/$/, '') + '/' + filename;
25
+ if (img?.saveToFile(dest, 'png')) return filename;
26
+ } catch (e) {
27
+ console.log('widget: icon fetch failed (' + url + '): ' + e);
28
+ }
29
+ return null;
30
+ }
31
+
32
+ type PublishEntry = { label: string; sublabel?: string; deepLink?: string; iconUrl?: string; icon?: string; badge?: number };
33
+ type PublishPayload = {
34
+ template?: 'icon-grid' | 'stat' | 'list';
35
+ title?: string;
36
+ entries?: PublishEntry[];
37
+ stat?: { value: string; label?: string; sublabel?: string };
38
+ deepLink?: string;
39
+ slot?: 'launcher' | 'data';
40
+ };
41
+
42
+ export function registerWidgetHandlers(): void {
43
+ // Publish a payload the home-screen widget renders. Downloads any entry.iconUrl into the shared
44
+ // container (→ entry.icon filename) and writes the JSON to the App Group (iOS) / SharedPreferences
45
+ // (Android), then asks the OS to reload the widget. Pass `{}`/no entries to clear.
46
+ bridge.register('widget.publish', async (payload: PublishPayload) => {
47
+ const template = payload?.template ?? 'icon-grid';
48
+ const entries = (payload?.entries ?? []).filter((e) => e && e.label);
49
+ const key = payloadKeyFor(payload?.slot);
50
+
51
+ if (isIOS) {
52
+ const group = iosAppGroup();
53
+ const containerDir = NSFileManager.defaultManager
54
+ .containerURLForSecurityApplicationGroupIdentifier(group)?.path;
55
+ // fetch icons into the container (in parallel), rewriting iconUrl → icon filename (slot-scoped
56
+ // so a launcher/data publish never overwrites the other's tiles)
57
+ const prefix = payload?.slot === 'data' ? 'd' : 'l';
58
+ if (containerDir) {
59
+ await Promise.all(entries.map(async (e, i) => {
60
+ if (e.iconUrl) e.icon = (await saveIcon(e.iconUrl, containerDir, `${prefix}_${i}.png`)) ?? undefined;
61
+ delete e.iconUrl;
62
+ }));
63
+ }
64
+ const json = JSON.stringify({ template, title: payload.title, entries, stat: payload.stat, deepLink: payload.deepLink });
65
+ const defaults = NSUserDefaults.alloc().initWithSuiteName(group);
66
+ defaults?.setObjectForKey(json, key);
67
+ if (typeof AppwrapWidgetReload !== 'undefined') AppwrapWidgetReload.reloadAll();
68
+ return { published: true };
69
+ }
70
+
71
+ if (isAndroid) {
72
+ // Android ships the launcher widget only; a `data`-slot publish still persists (for a future
73
+ // data provider) but there's no receiver to refresh, so it's a no-op render-wise today.
74
+ const ctx = Utils.android.getApplicationContext();
75
+ const filesDir = ctx.getFilesDir().getAbsolutePath();
76
+ const prefix = payload?.slot === 'data' ? 'd' : 'l';
77
+ await Promise.all(entries.map(async (e, i) => {
78
+ if (e.iconUrl) e.icon = (await saveIcon(e.iconUrl, filesDir, `${prefix}_${i}.png`)) ?? undefined;
79
+ delete e.iconUrl;
80
+ }));
81
+ const json = JSON.stringify({ template, title: payload.title, entries, stat: payload.stat, deepLink: payload.deepLink });
82
+ ctx.getSharedPreferences(PREFS_NAME, 0).edit().putString(key, json).apply();
83
+ if (payload?.slot === 'data') return { published: true };
84
+ // Kick the provider to re-render: broadcast an APPWIDGET_UPDATE to our widget ids.
85
+ const mgr = android.appwidget.AppWidgetManager.getInstance(ctx);
86
+ const cn = new android.content.ComponentName(ctx, 'cc.livx.appwrap.AppwrapWidgetProvider');
87
+ const ids = mgr.getAppWidgetIds(cn);
88
+ const intent = new android.content.Intent(android.appwidget.AppWidgetManager.ACTION_APPWIDGET_UPDATE);
89
+ intent.setComponent(cn);
90
+ intent.putExtra(android.appwidget.AppWidgetManager.EXTRA_APPWIDGET_IDS, ids);
91
+ ctx.sendBroadcast(intent);
92
+ return { published: true };
93
+ }
94
+
95
+ return { published: false, reason: 'unsupported' as const };
96
+ });
97
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * PURE payload → "who is this from" mapping for LOCAL notifications (no NativeScript
3
+ * imports; bun-tested). Shared by the iOS communication-notification path and the
4
+ * Android per-sender-channel path so both derive the same identity from the same rules.
5
+ *
6
+ * When a `sender` name is given it becomes the notification's "from" title and the
7
+ * original `title` is demoted to the subtitle (dropped when it would just duplicate
8
+ * the sender). `icon` (URL or data-URI) is the avatar / large icon. `useIdentity` is
9
+ * true when either a sender or an icon is present — i.e. when the platform should
10
+ * present a custom-sender notification instead of the plain host-app one.
11
+ */
12
+
13
+ export interface NotifIdentityInput {
14
+ title?: string;
15
+ body?: string;
16
+ sender?: string;
17
+ icon?: string;
18
+ }
19
+
20
+ export interface NotifIdentity {
21
+ /** Main display title — the sender name when provided, else the original title. */
22
+ title: string;
23
+ /** Original title demoted to subtitle ('' = omit). */
24
+ subtitle: string;
25
+ body: string;
26
+ /** Display name for the communication sender / channel ('' = none). */
27
+ senderName: string;
28
+ /** Icon URL or data-URI for the avatar / large icon ('' = none). */
29
+ iconUrl: string;
30
+ /** True when a custom identity (sender and/or icon) should be applied. */
31
+ useIdentity: boolean;
32
+ }
33
+
34
+ export function notifIdentity(o: NotifIdentityInput): NotifIdentity {
35
+ const title = String(o.title ?? '');
36
+ const body = String(o.body ?? '');
37
+ const senderName = o.sender ? String(o.sender) : '';
38
+ const iconUrl = o.icon ? String(o.icon) : '';
39
+ const useIdentity = !!(senderName || iconUrl);
40
+ // A named sender becomes the "from" title; the original title is demoted to the
41
+ // subtitle (dropped when it would merely repeat the sender name).
42
+ const displayTitle = senderName || title;
43
+ const subtitle = senderName && title && title !== senderName ? title : '';
44
+ return { title: displayTitle, subtitle, body, senderName, iconUrl, useIdentity };
45
+ }
@@ -0,0 +1,79 @@
1
+ package cc.livx.appwrap
2
+
3
+ import android.app.PendingIntent
4
+ import android.appwidget.AppWidgetManager
5
+ import android.appwidget.AppWidgetProvider
6
+ import android.content.Context
7
+ import android.content.Intent
8
+ import android.graphics.BitmapFactory
9
+ import android.net.Uri
10
+ import android.view.View
11
+ import android.widget.RemoteViews
12
+ import org.json.JSONObject
13
+ import java.io.File
14
+
15
+ /**
16
+ * Home-screen launcher widget (Android parity of the iOS WidgetKit launcher). Reads the payload the
17
+ * `widget.publish` handler wrote into SharedPreferences ("appwrap.widget" / key "appwrap.widget") and
18
+ * renders up to 4 icon-grid tiles; each tile deep-links back into the app via its entry's URL. Icons
19
+ * are PNGs the handler saved into the app files dir. Resource ids are looked up by name so the module
20
+ * needs no compile-time reference to the host app's R class.
21
+ */
22
+ class AppwrapWidgetProvider : AppWidgetProvider() {
23
+ private val tiles = listOf("tile0", "tile1", "tile2", "tile3")
24
+
25
+ override fun onUpdate(ctx: Context, mgr: AppWidgetManager, ids: IntArray) {
26
+ for (id in ids) render(ctx, mgr, id)
27
+ }
28
+
29
+ private fun res(ctx: Context, type: String, name: String) =
30
+ ctx.resources.getIdentifier(name, type, ctx.packageName)
31
+
32
+ private fun render(ctx: Context, mgr: AppWidgetManager, id: Int) {
33
+ val rv = RemoteViews(ctx.packageName, res(ctx, "layout", "appwrap_widget"))
34
+ val raw = ctx.getSharedPreferences("appwrap.widget", Context.MODE_PRIVATE)
35
+ .getString("appwrap.widget", null)
36
+ val payload = raw?.let { runCatching { JSONObject(it) }.getOrNull() }
37
+ val title = payload?.optString("title") ?: ""
38
+ rv.setTextViewText(res(ctx, "id", "appwrap_widget_title"), title)
39
+ rv.setViewVisibility(res(ctx, "id", "appwrap_widget_title"),
40
+ if (title.isEmpty()) View.GONE else View.VISIBLE)
41
+ val entries = payload?.optJSONArray("entries")
42
+
43
+ for (i in tiles.indices) {
44
+ val tileId = res(ctx, "id", tiles[i])
45
+ val e = if (entries != null && i < entries.length()) entries.optJSONObject(i) else null
46
+ if (e == null) { rv.setViewVisibility(tileId, View.INVISIBLE); continue }
47
+ rv.setViewVisibility(tileId, View.VISIBLE)
48
+ rv.setTextViewText(res(ctx, "id", "${tiles[i]}_label"), e.optString("label"))
49
+ // unread-count badge on the icon's top-right corner (absent/0 → hidden)
50
+ val badgeId = res(ctx, "id", "${tiles[i]}_badge")
51
+ val badge = e.optInt("badge", 0)
52
+ if (badge > 0) {
53
+ rv.setTextViewText(badgeId, if (badge > 99) "99+" else badge.toString())
54
+ rv.setViewVisibility(badgeId, View.VISIBLE)
55
+ } else rv.setViewVisibility(badgeId, View.GONE)
56
+ val icon = e.optString("icon", "")
57
+ if (icon.isNotEmpty()) {
58
+ val f = File(ctx.filesDir, icon)
59
+ if (f.exists()) BitmapFactory.decodeFile(f.absolutePath)
60
+ ?.let { rv.setImageViewBitmap(res(ctx, "id", "${tiles[i]}_icon"), it) }
61
+ }
62
+ val link = e.optString("deepLink", "")
63
+ if (link.isNotEmpty()) {
64
+ // Target the app's OWN launch activity explicitly + carry the deep link as data — the
65
+ // shell reads intent.getData() (like a pinned shortcut). Relying on an ACTION_VIEW
66
+ // intent-filter match would need an App Links autoVerify filter for https hosts, which
67
+ // the shell doesn't declare; an explicit component always opens the app.
68
+ val intent = (ctx.packageManager.getLaunchIntentForPackage(ctx.packageName)
69
+ ?: Intent(Intent.ACTION_VIEW).setPackage(ctx.packageName))
70
+ .setAction(Intent.ACTION_VIEW)
71
+ .setData(Uri.parse(link))
72
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
73
+ val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
74
+ rv.setOnClickPendingIntent(tileId, PendingIntent.getActivity(ctx, i, intent, flags))
75
+ }
76
+ }
77
+ mgr.updateAppWidget(id, rv)
78
+ }
79
+ }