@livx.cc/appwrap 0.47.3 → 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.
- package/package.json +1 -1
- package/runtime/App_Resources/Android/src/main/AndroidManifest.xml +3 -0
- package/runtime/app/shell/capabilities.manifest.ts +65 -3
- package/runtime/app/shell/custom-webview.android.ts +9 -0
- package/runtime/app/shell/handlers-android.ts +54 -9
- package/runtime/app/shell/handlers-extended.ts +82 -4
- package/runtime/app/shell/handlers-system.ts +48 -1
- package/runtime/app/shell/handlers-widget.ts +97 -0
- package/runtime/app/shell/notif-identity.ts +45 -0
- package/runtime/modules-native/widget/App_Resources/Android/src/main/java/cc/livx/appwrap/AppwrapWidgetProvider.kt +79 -0
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/drawable/appwrap_badge_bg.xml +7 -0
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/layout/appwrap_widget.xml +70 -0
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/values/appwrap_widget_styles.xml +40 -0
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/xml/appwrap_widget_info.xml +13 -0
- package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/AppwrapWidget.entitlements +10 -0
- package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/AppwrapWidget.swift +275 -0
- package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/Info.plist +25 -0
- package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/extension.json +9 -0
- package/runtime/modules-native/widget/App_Resources/iOS/src/AppwrapWidgetReload.swift +18 -0
- package/runtime/references.d.ts +1 -0
- package/runtime/tests/notif-identity.test.ts +43 -0
- package/runtime-desktop/chrome/appwrap-browser-chrome.html +70 -5
- package/runtime-desktop/crates/webview-control/src/lib.rs +2 -1
- package/runtime-desktop/crates/webview-control/src/wkwebview_backend.rs +22 -9
- package/runtime-desktop/src-tauri/src/browser_tab.rs +379 -20
- package/runtime-desktop/src-tauri/src/main.rs +191 -11
- package/src/cli.ts +74 -0
- package/src/config.ts +8 -1
- package/src/plugin/host.ts +5 -0
- package/src/plugin/types.ts +1 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
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(
|
|
83
|
+
.setContentTitle(ident.title)
|
|
52
84
|
.setAutoCancel(true);
|
|
53
|
-
if (
|
|
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
|
-
|
|
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 =
|
|
221
|
-
if (
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="utf-8"?>
|
|
2
|
+
<!-- Red pill background for the widget tile's unread-count badge. -->
|
|
3
|
+
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
|
4
|
+
<solid android:color="#FF3B30" />
|
|
5
|
+
<stroke android:width="1.5dp" android:color="#FFFFFF" />
|
|
6
|
+
<corners android:radius="9dp" />
|
|
7
|
+
</shape>
|