@livx.cc/appwrap 0.50.1 → 0.51.1
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/custom-webview.android.ts +39 -4
- package/runtime/app/shell/env-banner.ts +28 -4
- package/runtime/app/shell/env-switcher.ts +40 -13
- package/runtime/modules-native/widget/App_Resources/Android/src/main/java/cc/livx/appwrap/AppwrapWidgetProvider.kt +45 -6
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/layout/appwrap_widget.xml +77 -24
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/values/appwrap_widget_colors.xml +10 -0
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/values/appwrap_widget_styles.xml +39 -12
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/values-night/appwrap_widget_colors.xml +9 -0
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/xml/appwrap_widget_info.xml +5 -1
package/package.json
CHANGED
|
@@ -107,6 +107,14 @@ export class CustomWebView extends WebView {
|
|
|
107
107
|
// object, robust to NS re-wrapping the arg passed into the ChromeClient callback). WeakRef
|
|
108
108
|
// so a destroyed view can be GC'd; entry pruned on dispose.
|
|
109
109
|
private static registry = new Map<number, WeakRef<CustomWebView>>();
|
|
110
|
+
// Retired asset-serving WebViewClients (app/file loader ONLY — see disposeNativeView), kept
|
|
111
|
+
// process-alive so a shouldInterceptRequest already in flight on a background thread when the view is
|
|
112
|
+
// disposed can't fire against a GC'd JS peer → "Cannot find runtime for instance". Holding the (JS-peer)
|
|
113
|
+
// object here keeps NS's per-object runtime mapping resolvable. Server mode installs no interceptor, so
|
|
114
|
+
// its clients are NOT parked (all their callbacks are main-thread → safe to GC). Growth is bounded by
|
|
115
|
+
// the number of interceptor-bearing WebViews that dispose — note handlers-background can spin up an
|
|
116
|
+
// offscreen WebView per background task, so this is per-disposed-interceptor-view, not strictly one.
|
|
117
|
+
private static retiredClients = new Set<android.webkit.WebViewClient>();
|
|
110
118
|
static forNative(view: android.webkit.WebView): CustomWebView | undefined {
|
|
111
119
|
const owner = CustomWebView.registry.get(view.hashCode())?.deref();
|
|
112
120
|
// identityHashCode is not unique: on a (rare) collision the map holds the LAST writer —
|
|
@@ -155,9 +163,20 @@ export class CustomWebView extends WebView {
|
|
|
155
163
|
disposeNativeView(): void {
|
|
156
164
|
const wv = this.android as android.webkit.WebView | undefined;
|
|
157
165
|
if (wv) CustomWebView.registry.delete(wv.hashCode());
|
|
158
|
-
//
|
|
159
|
-
this.webViewClient = null;
|
|
166
|
+
// WebChromeClient (onJsPrompt) only fires on the main thread → safe to release for GC now.
|
|
160
167
|
this.webChromeClient = null;
|
|
168
|
+
// WebViewClient is different: shouldInterceptRequest can be dispatched on a BACKGROUND thread, so a
|
|
169
|
+
// request already in flight when the view tears down would fire AFTER dispose. Releasing the client's
|
|
170
|
+
// JS peer here would let that late off-thread call crash with "Cannot find runtime for instance".
|
|
171
|
+
// Park it in a process-lived set so the peer stays registered for any straggler callback — but ONLY
|
|
172
|
+
// when the interceptor is actually installed (app/file loader). Server mode installs no
|
|
173
|
+
// shouldInterceptRequest (see createAssetServingClient), so its client has no off-thread callback to
|
|
174
|
+
// outlive dispose: parking it would be pure dead retention (an accumulating leak across background-
|
|
175
|
+
// task WebViews), so just release it for GC.
|
|
176
|
+
if (this.webViewClient) {
|
|
177
|
+
if (SHELL_CONFIG.loader !== 'server') CustomWebView.retiredClients.add(this.webViewClient);
|
|
178
|
+
this.webViewClient = null;
|
|
179
|
+
}
|
|
161
180
|
super.disposeNativeView();
|
|
162
181
|
}
|
|
163
182
|
|
|
@@ -307,11 +326,27 @@ function createAssetServingClient(): android.webkit.WebViewClient {
|
|
|
307
326
|
return new android.webkit.WebResourceResponse(mimeFor(ext, rel), 'utf-8', 200, 'OK', outHeaders, pin);
|
|
308
327
|
};
|
|
309
328
|
|
|
329
|
+
// WebViewClient callbacks. All are dispatched on the UI/main thread EXCEPT shouldInterceptRequest,
|
|
330
|
+
// which Android runs on a BACKGROUND thread (ShouldInterceptRequestMediator). NativeScript resolves
|
|
331
|
+
// the JS peer for a callback's instance via a per-object runtime lookup (Runtime.getObjectRuntime);
|
|
332
|
+
// if that peer is momentarily unregistered — freshly created and not yet mapped on boot, or disposed
|
|
333
|
+
// and GC'd on relaunch / RESTART-from-crash — the off-thread call throws
|
|
334
|
+
// `NativeScriptException: Cannot find runtime for instance` (the red NS crash screen). NS marshals a
|
|
335
|
+
// background-thread call to the main thread fine ONCE the peer is mapped, so this is a registration/
|
|
336
|
+
// lifetime race, not a plain GC one — which is why the earlier strong-JS-ref fix didn't cover the boot path.
|
|
337
|
+
//
|
|
338
|
+
// In server mode (loader:'server') the page loads from a real https `serverUrl`: there are NO
|
|
339
|
+
// https://appwrap.local requests and `backendOrigin` is same-origin, so the asset interceptor does
|
|
340
|
+
// ZERO work — yet it is the ONLY off-main-thread callback, i.e. the only one exposed to that race.
|
|
341
|
+
// So OMIT it in server mode (the conditional spread expands to nothing): Android's default pure-Java
|
|
342
|
+
// interception runs, NS is never crossed into JS off-thread, and the crash is structurally impossible.
|
|
343
|
+
// app/file loaders MUST serve bundled www/ assets, so they keep it (and retain the client for the
|
|
344
|
+
// process lifetime — see CustomWebView.disposeNativeView — so a late in-flight call can't hit a dead peer).
|
|
310
345
|
// NS runtime adds `.extend` on Java classes; it's not in the static typings, hence the cast.
|
|
311
346
|
assetClientClass = (android.webkit.WebViewClient as any).extend({
|
|
312
347
|
// Modern (API 21+) overload passes a WebResourceRequest; the legacy overload passed a string URL.
|
|
313
348
|
// We defensively handle both, so the param is the union.
|
|
314
|
-
shouldInterceptRequest(
|
|
349
|
+
...(SHELL_CONFIG.loader === 'server' ? {} : { shouldInterceptRequest(
|
|
315
350
|
_view: android.webkit.WebView,
|
|
316
351
|
request: android.webkit.WebResourceRequest | string
|
|
317
352
|
): android.webkit.WebResourceResponse | null {
|
|
@@ -343,7 +378,7 @@ function createAssetServingClient(): android.webkit.WebViewClient {
|
|
|
343
378
|
return new android.webkit.WebResourceResponse(
|
|
344
379
|
'text/plain', 'utf-8', 404, 'Not Found', new java.util.HashMap<string, string>(), emptyStream()
|
|
345
380
|
);
|
|
346
|
-
},
|
|
381
|
+
} }),
|
|
347
382
|
|
|
348
383
|
// External URL schemes (mailto:/tel:/sms:/geo:/intent:/webcal:/market:/…) can't be rendered by the
|
|
349
384
|
// WebView — without this override it tries to LOAD them and dumps a full-page net::ERR_UNKNOWN_URL_SCHEME
|
|
@@ -194,10 +194,32 @@ function hideIOSBanner(): void {
|
|
|
194
194
|
}
|
|
195
195
|
|
|
196
196
|
// ── Android ──────────────────────────────────────────────────────────
|
|
197
|
+
/**
|
|
198
|
+
* Cold-relaunch safe entry: on a relaunch (main-page onPageLoaded) the Activity's window is not yet
|
|
199
|
+
* attached, so `foregroundActivity` can be null AND `getRootWindowInsets()` returns null — mounting
|
|
200
|
+
* inline then lands the pill INSIDE the nav-bar zone (bottomMargin computed off a 0 inset) where it's
|
|
201
|
+
* occluded by the home affordance and reads as "the banner disappeared". So: wait for an activity, then
|
|
202
|
+
* defer the actual mount to `decorView.post` (runs after attach + first layout on the UI thread), when
|
|
203
|
+
* insets are real. In-session callers (refreshEnvBanner) hit the same path — a one-frame post is harmless.
|
|
204
|
+
*/
|
|
205
|
+
let androidMountRetries = 0; // bounds the boot "activity not ready" retry so it can't spin forever
|
|
206
|
+
|
|
197
207
|
function showAndroidBanner(): void {
|
|
198
208
|
if (androidView) return;
|
|
199
209
|
const activity = Application.android.foregroundActivity || Application.android.startActivity;
|
|
200
|
-
if (!activity)
|
|
210
|
+
if (!activity) {
|
|
211
|
+
if (androidMountRetries++ < 20) setTimeout(() => runOnAndroidUi(showAndroidBanner), 100); // boot: activity not ready — retry, bounded (~2s)
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
androidMountRetries = 0;
|
|
215
|
+
activity.getWindow().getDecorView().post(new java.lang.Runnable({ run() { mountAndroidBanner(activity); } }));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function mountAndroidBanner(activity: android.app.Activity): void {
|
|
219
|
+
if (androidView) return; // a second deferred mount raced in — keep the first
|
|
220
|
+
// Re-gate at the final step: the override may have been cleared (reset / switch to default / hideEnvBanner)
|
|
221
|
+
// during the deferred post or the boot retry window — don't mount a stale banner the caller no longer wants.
|
|
222
|
+
if (!isEnvSwitcherEnabled() || !isNonDefaultOverride()) return;
|
|
201
223
|
const density = activity.getResources().getDisplayMetrics().density;
|
|
202
224
|
const padH = Math.round(14 * density), padV = Math.round(6 * density);
|
|
203
225
|
|
|
@@ -254,13 +276,15 @@ function showAndroidBanner(): void {
|
|
|
254
276
|
armShrink();
|
|
255
277
|
}
|
|
256
278
|
|
|
257
|
-
/** Bottom system-inset (gesture/nav bar) in px
|
|
279
|
+
/** Bottom system-inset (gesture/nav bar) in px. Falls back to ~48dp (a typical nav-bar height) rather
|
|
280
|
+
* than 0 if the read fails, so the pill never lands INSIDE the nav bar on a device/timing where insets
|
|
281
|
+
* are unavailable — being a touch too high is harmless; being occluded reads as "missing". */
|
|
258
282
|
function androidBottomInsetPx(activity: android.app.Activity): number {
|
|
259
283
|
try {
|
|
260
284
|
const wi = activity.getWindow().getDecorView().getRootWindowInsets();
|
|
261
285
|
if (wi) return wi.getSystemWindowInsetBottom();
|
|
262
|
-
} catch (e) { /* pre-attach / older API — fall back
|
|
263
|
-
return
|
|
286
|
+
} catch (e) { /* pre-attach / older API — fall back below */ }
|
|
287
|
+
return Math.round(48 * activity.getResources().getDisplayMetrics().density);
|
|
264
288
|
}
|
|
265
289
|
|
|
266
290
|
function renderAndroid(): void {
|
|
@@ -143,24 +143,26 @@ export async function showEnvSwitcher(): Promise<void> {
|
|
|
143
143
|
}
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
-
// ── Deep-link entry (`<scheme>://env?url=<encoded-url>`)
|
|
147
|
-
// A
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
146
|
+
// ── Deep-link entry (`<scheme>://env?to=<name>` | `?url=<encoded-url>`) ────────────────────────────
|
|
147
|
+
// A shared link can auto-open the app on a given env. Two additive forms: `?to=<name>` switches to a
|
|
148
|
+
// CONFIGURED preset by label (TRUSTED — bypasses the allowlist, like the manual switcher's presets);
|
|
149
|
+
// `?url=<encoded>` is a free-form target still gated by `isUrlAllowed` (anchored allowPattern, default-
|
|
150
|
+
// deny). Both go through a confirm prompt and apply via the EXACT SAME `applySwitch` path as a manual
|
|
151
|
+
// switch. events.ts owns the WebView-ready timing (warm: now; cold launch: buffered, replayed after
|
|
152
|
+
// the PWA handshake).
|
|
151
153
|
|
|
152
154
|
export type EnvDeepLinkDecision =
|
|
153
155
|
| { action: 'switch'; url: string }
|
|
154
156
|
| { action: 'rejected'; url: string }
|
|
155
157
|
| { action: 'ignored' };
|
|
156
158
|
|
|
157
|
-
/** Decode
|
|
158
|
-
function
|
|
159
|
+
/** Decode a query param by name from a `<scheme>://env?…` link. Null if absent/empty/undecodable. */
|
|
160
|
+
function parseQueryParam(link: string, name: string): string | null {
|
|
159
161
|
const q = String(link || '').indexOf('?');
|
|
160
162
|
if (q < 0) return null;
|
|
161
163
|
for (const part of link.slice(q + 1).split('&')) {
|
|
162
164
|
const eq = part.indexOf('=');
|
|
163
|
-
if ((eq < 0 ? part : part.slice(0, eq)) !==
|
|
165
|
+
if ((eq < 0 ? part : part.slice(0, eq)) !== name) continue;
|
|
164
166
|
try {
|
|
165
167
|
const decoded = decodeURIComponent((eq < 0 ? '' : part.slice(eq + 1)).replace(/\+/g, '%20')).trim();
|
|
166
168
|
return decoded || null;
|
|
@@ -171,6 +173,19 @@ function parseEnvTargetUrl(link: string): string | null {
|
|
|
171
173
|
return null;
|
|
172
174
|
}
|
|
173
175
|
|
|
176
|
+
/** Decode the `url` query param from a `<scheme>://env?url=<encoded>` link. Null if absent/undecodable. */
|
|
177
|
+
function parseEnvTargetUrl(link: string): string | null {
|
|
178
|
+
return parseQueryParam(link, 'url');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Resolve a `?to=`/`?env=` preset NAME against `envs` by label (case-insensitive). Null if no name given. */
|
|
182
|
+
function resolvePresetByName(link: string): { name: string; preset: { label: string; url: string } | undefined } | null {
|
|
183
|
+
const name = parseQueryParam(link, 'to') ?? parseQueryParam(link, 'env');
|
|
184
|
+
if (name === null) return null;
|
|
185
|
+
const preset = (SHELL_CONFIG.envSwitcher?.envs ?? []).find((e) => e.label.toLowerCase() === name.toLowerCase());
|
|
186
|
+
return { name, preset };
|
|
187
|
+
}
|
|
188
|
+
|
|
174
189
|
/** Structurally an env-switch deep link (`<scheme>://env…`) AND the switcher is enabled — the signal
|
|
175
190
|
* events.ts uses to CONSUME the link (never forward an env link to the PWA). Inert (false) when the
|
|
176
191
|
* feature is disabled/absent, so such a link simply falls through to the normal PWA deep-link path. */
|
|
@@ -179,14 +194,26 @@ export function isEnvDeepLink(link: string): boolean {
|
|
|
179
194
|
}
|
|
180
195
|
|
|
181
196
|
/**
|
|
182
|
-
* PURE decision for an inbound `<scheme>://env
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
197
|
+
* PURE decision for an inbound `<scheme>://env?…` deep link — no dialogs, no side effects (unit-testable,
|
|
198
|
+
* mirrors env-deeplink-security.test.ts). Two additive forms:
|
|
199
|
+
*
|
|
200
|
+
* • `?to=<name>` (alias `?env=<name>`) — switch to a CONFIGURED preset by label, case-insensitive. A
|
|
201
|
+
* resolved preset is TRUSTED and BYPASSES the allowlist (it's a declared env, exactly like the manual
|
|
202
|
+
* switcher trusts its presets and only allowlists free-form "Other"). A name matching NO preset →
|
|
203
|
+
* `rejected` (NEVER switch, NEVER fall through to `?url=`). `to` WINS when both `to`/`env` present, and
|
|
204
|
+
* takes PRECEDENCE over `?url=` when both are present.
|
|
205
|
+
* • `?url=<encoded>` — free-form target, allowlist-GATED: MUST pass the SAME `isUrlAllowed` gate as the
|
|
206
|
+
* "Other" custom URL (anchored allowPattern, default-deny). Allowlisted → `switch`; else → `rejected`.
|
|
207
|
+
*
|
|
208
|
+
* Disabled feature / non-env link / neither param present (or malformed/undecodable) → `ignored`.
|
|
187
209
|
*/
|
|
188
210
|
export function decideEnvDeepLink(link: string): EnvDeepLinkDecision {
|
|
189
211
|
if (!isEnvSwitcherEnabled() || hostOf(link) !== 'env') return { action: 'ignored' };
|
|
212
|
+
// Preset-by-name takes precedence and bypasses the allowlist (trusted, configured env).
|
|
213
|
+
const named = resolvePresetByName(link);
|
|
214
|
+
if (named) {
|
|
215
|
+
return named.preset ? { action: 'switch', url: named.preset.url } : { action: 'rejected', url: named.name };
|
|
216
|
+
}
|
|
190
217
|
const target = parseEnvTargetUrl(link);
|
|
191
218
|
if (!target) return { action: 'ignored' };
|
|
192
219
|
if (!isUrlAllowed(target)) return { action: 'rejected', url: target };
|
|
@@ -5,7 +5,13 @@ import android.appwidget.AppWidgetManager
|
|
|
5
5
|
import android.appwidget.AppWidgetProvider
|
|
6
6
|
import android.content.Context
|
|
7
7
|
import android.content.Intent
|
|
8
|
+
import android.graphics.Bitmap
|
|
8
9
|
import android.graphics.BitmapFactory
|
|
10
|
+
import android.graphics.BitmapShader
|
|
11
|
+
import android.graphics.Canvas
|
|
12
|
+
import android.graphics.Paint
|
|
13
|
+
import android.graphics.RectF
|
|
14
|
+
import android.graphics.Shader
|
|
9
15
|
import android.net.Uri
|
|
10
16
|
import android.view.View
|
|
11
17
|
import android.widget.RemoteViews
|
|
@@ -15,12 +21,14 @@ import java.io.File
|
|
|
15
21
|
/**
|
|
16
22
|
* Home-screen launcher widget (Android parity of the iOS WidgetKit launcher). Reads the payload the
|
|
17
23
|
* `widget.publish` handler wrote into SharedPreferences ("appwrap.widget" / key "appwrap.widget") and
|
|
18
|
-
* renders up to
|
|
19
|
-
* are PNGs the handler saved into the app files dir. Resource ids are looked up by
|
|
20
|
-
* needs no compile-time reference to the host app's R class.
|
|
24
|
+
* renders up to 12 icon-grid tiles across 4 columns; each tile deep-links back into the app via its
|
|
25
|
+
* entry's URL. Icons are PNGs the handler saved into the app files dir. Resource ids are looked up by
|
|
26
|
+
* name so the module needs no compile-time reference to the host app's R class.
|
|
21
27
|
*/
|
|
22
28
|
class AppwrapWidgetProvider : AppWidgetProvider() {
|
|
23
|
-
private val
|
|
29
|
+
private val columns = 4
|
|
30
|
+
private val tiles = (0 until 12).map { "tile$it" }
|
|
31
|
+
private val rows = (0 until 3).map { "row$it" }
|
|
24
32
|
|
|
25
33
|
override fun onUpdate(ctx: Context, mgr: AppWidgetManager, ids: IntArray) {
|
|
26
34
|
for (id in ids) render(ctx, mgr, id)
|
|
@@ -29,6 +37,27 @@ class AppwrapWidgetProvider : AppWidgetProvider() {
|
|
|
29
37
|
private fun res(ctx: Context, type: String, name: String) =
|
|
30
38
|
ctx.resources.getIdentifier(name, type, ctx.packageName)
|
|
31
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Round an app icon to look native on the home screen (iOS-parity: iOS clips to a
|
|
42
|
+
* RoundedRectangle ~24% of the icon size). RemoteViews ImageViews can't clip via an outline, so we
|
|
43
|
+
* center-crop the source to a square and redraw it into a rounded bitmap at full source resolution
|
|
44
|
+
* — the widget host then downscales a crisp, antialiased image rather than a hard-edged square.
|
|
45
|
+
*/
|
|
46
|
+
private fun rounded(src: Bitmap): Bitmap {
|
|
47
|
+
val size = minOf(src.width, src.height)
|
|
48
|
+
val sq = if (src.width != src.height)
|
|
49
|
+
Bitmap.createBitmap(src, (src.width - size) / 2, (src.height - size) / 2, size, size)
|
|
50
|
+
else src
|
|
51
|
+
val out = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
|
52
|
+
val canvas = Canvas(out)
|
|
53
|
+
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
54
|
+
shader = BitmapShader(sq, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
|
|
55
|
+
}
|
|
56
|
+
val r = size * 0.22f
|
|
57
|
+
canvas.drawRoundRect(RectF(0f, 0f, size.toFloat(), size.toFloat()), r, r, paint)
|
|
58
|
+
return out
|
|
59
|
+
}
|
|
60
|
+
|
|
32
61
|
private fun render(ctx: Context, mgr: AppWidgetManager, id: Int) {
|
|
33
62
|
val rv = RemoteViews(ctx.packageName, res(ctx, "layout", "appwrap_widget"))
|
|
34
63
|
val raw = ctx.getSharedPreferences("appwrap.widget", Context.MODE_PRIVATE)
|
|
@@ -39,10 +68,20 @@ class AppwrapWidgetProvider : AppWidgetProvider() {
|
|
|
39
68
|
rv.setViewVisibility(res(ctx, "id", "appwrap_widget_title"),
|
|
40
69
|
if (title.isEmpty()) View.GONE else View.VISIBLE)
|
|
41
70
|
val entries = payload?.optJSONArray("entries")
|
|
71
|
+
val count = entries?.length() ?: 0
|
|
72
|
+
|
|
73
|
+
// A row with no entries at all is GONE so it contributes no height (the grid stays top-packed
|
|
74
|
+
// and leftover widget height falls to the bottom). A row that is only PARTLY filled stays
|
|
75
|
+
// VISIBLE and hides its trailing tiles as INVISIBLE, so a short final row keeps its tiles in
|
|
76
|
+
// the leading columns rather than centring them (iOS Grid's short-final-row behaviour).
|
|
77
|
+
for (r in rows.indices) {
|
|
78
|
+
rv.setViewVisibility(res(ctx, "id", rows[r]),
|
|
79
|
+
if (r * columns < count) View.VISIBLE else View.GONE)
|
|
80
|
+
}
|
|
42
81
|
|
|
43
82
|
for (i in tiles.indices) {
|
|
44
83
|
val tileId = res(ctx, "id", tiles[i])
|
|
45
|
-
val e = if (entries != null && i <
|
|
84
|
+
val e = if (entries != null && i < count) entries.optJSONObject(i) else null
|
|
46
85
|
if (e == null) { rv.setViewVisibility(tileId, View.INVISIBLE); continue }
|
|
47
86
|
rv.setViewVisibility(tileId, View.VISIBLE)
|
|
48
87
|
rv.setTextViewText(res(ctx, "id", "${tiles[i]}_label"), e.optString("label"))
|
|
@@ -57,7 +96,7 @@ class AppwrapWidgetProvider : AppWidgetProvider() {
|
|
|
57
96
|
if (icon.isNotEmpty()) {
|
|
58
97
|
val f = File(ctx.filesDir, icon)
|
|
59
98
|
if (f.exists()) BitmapFactory.decodeFile(f.absolutePath)
|
|
60
|
-
?.let { rv.setImageViewBitmap(res(ctx, "id", "${tiles[i]}_icon"), it) }
|
|
99
|
+
?.let { rv.setImageViewBitmap(res(ctx, "id", "${tiles[i]}_icon"), rounded(it)) }
|
|
61
100
|
}
|
|
62
101
|
val link = e.optString("deepLink", "")
|
|
63
102
|
if (link.isNotEmpty()) {
|
package/runtime/modules-native/widget/App_Resources/Android/src/main/res/layout/appwrap_widget.xml
CHANGED
|
@@ -1,32 +1,36 @@
|
|
|
1
1
|
<?xml version="1.0" encoding="utf-8"?>
|
|
2
|
-
<!-- AppWrap launcher widget: title + a
|
|
3
|
-
AppwrapWidgetProvider from the published payload
|
|
4
|
-
|
|
2
|
+
<!-- AppWrap launcher widget: title + a 4-column icon grid of deep-linked tiles (up to 3 rows / 12
|
|
3
|
+
tiles), populated by AppwrapWidgetProvider from the published payload.
|
|
4
|
+
|
|
5
|
+
LAYOUT CONTRACT (iOS parity — see AppwrapWidget.swift `gridView`):
|
|
6
|
+
* Rows are CONTENT-HEIGHT (wrap_content, NO layout_weight). The root is a vertical
|
|
7
|
+
LinearLayout at match_parent, so rows stack from the TOP and any leftover widget height
|
|
8
|
+
falls to the BOTTOM — the "app folder" feel. Giving rows `0dp + weight=1` (as this layout
|
|
9
|
+
once did) makes them greedily split the full height and float each icon to the centre of
|
|
10
|
+
its band, which strands the last row at the bottom behind a large dead gap on a
|
|
11
|
+
vertically-resized widget. Don't reintroduce weights here.
|
|
12
|
+
* A partially-filled row keeps its trailing tiles INVISIBLE (not GONE) so the short final row
|
|
13
|
+
stays aligned to the LEADING columns instead of centring. A fully-empty row is set GONE by
|
|
14
|
+
the provider so it consumes no height. -->
|
|
5
15
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
|
6
16
|
android:layout_width="match_parent"
|
|
7
17
|
android:layout_height="match_parent"
|
|
8
18
|
android:orientation="vertical"
|
|
9
|
-
android:background="
|
|
19
|
+
android:background="@color/appwrap_widget_bg"
|
|
10
20
|
android:padding="10dp">
|
|
11
21
|
|
|
12
22
|
<TextView
|
|
13
23
|
android:id="@+id/appwrap_widget_title"
|
|
14
24
|
android:layout_width="wrap_content"
|
|
15
25
|
android:layout_height="wrap_content"
|
|
16
|
-
android:textColor="
|
|
26
|
+
android:textColor="@color/appwrap_widget_title"
|
|
17
27
|
android:textStyle="bold"
|
|
18
28
|
android:textSize="12sp"
|
|
19
|
-
android:layout_marginBottom="
|
|
29
|
+
android:layout_marginBottom="8dp"
|
|
20
30
|
android:maxLines="1"
|
|
21
31
|
android:ellipsize="end" />
|
|
22
32
|
|
|
23
|
-
<LinearLayout
|
|
24
|
-
android:layout_width="match_parent"
|
|
25
|
-
android:layout_height="0dp"
|
|
26
|
-
android:layout_weight="1"
|
|
27
|
-
android:orientation="horizontal"
|
|
28
|
-
android:weightSum="2">
|
|
29
|
-
|
|
33
|
+
<LinearLayout android:id="@+id/row0" style="@style/AppwrapWidgetRow">
|
|
30
34
|
<LinearLayout android:id="@+id/tile0" style="@style/AppwrapWidgetTile">
|
|
31
35
|
<FrameLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginBottom="4dp">
|
|
32
36
|
<ImageView android:id="@+id/tile0_icon" style="@style/AppwrapWidgetIcon" />
|
|
@@ -34,7 +38,6 @@
|
|
|
34
38
|
</FrameLayout>
|
|
35
39
|
<TextView android:id="@+id/tile0_label" style="@style/AppwrapWidgetLabel" />
|
|
36
40
|
</LinearLayout>
|
|
37
|
-
|
|
38
41
|
<LinearLayout android:id="@+id/tile1" style="@style/AppwrapWidgetTile">
|
|
39
42
|
<FrameLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginBottom="4dp">
|
|
40
43
|
<ImageView android:id="@+id/tile1_icon" style="@style/AppwrapWidgetIcon" />
|
|
@@ -42,15 +45,6 @@
|
|
|
42
45
|
</FrameLayout>
|
|
43
46
|
<TextView android:id="@+id/tile1_label" style="@style/AppwrapWidgetLabel" />
|
|
44
47
|
</LinearLayout>
|
|
45
|
-
</LinearLayout>
|
|
46
|
-
|
|
47
|
-
<LinearLayout
|
|
48
|
-
android:layout_width="match_parent"
|
|
49
|
-
android:layout_height="0dp"
|
|
50
|
-
android:layout_weight="1"
|
|
51
|
-
android:orientation="horizontal"
|
|
52
|
-
android:weightSum="2">
|
|
53
|
-
|
|
54
48
|
<LinearLayout android:id="@+id/tile2" style="@style/AppwrapWidgetTile">
|
|
55
49
|
<FrameLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginBottom="4dp">
|
|
56
50
|
<ImageView android:id="@+id/tile2_icon" style="@style/AppwrapWidgetIcon" />
|
|
@@ -58,7 +52,6 @@
|
|
|
58
52
|
</FrameLayout>
|
|
59
53
|
<TextView android:id="@+id/tile2_label" style="@style/AppwrapWidgetLabel" />
|
|
60
54
|
</LinearLayout>
|
|
61
|
-
|
|
62
55
|
<LinearLayout android:id="@+id/tile3" style="@style/AppwrapWidgetTile">
|
|
63
56
|
<FrameLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginBottom="4dp">
|
|
64
57
|
<ImageView android:id="@+id/tile3_icon" style="@style/AppwrapWidgetIcon" />
|
|
@@ -67,4 +60,64 @@
|
|
|
67
60
|
<TextView android:id="@+id/tile3_label" style="@style/AppwrapWidgetLabel" />
|
|
68
61
|
</LinearLayout>
|
|
69
62
|
</LinearLayout>
|
|
63
|
+
<LinearLayout android:id="@+id/row1" style="@style/AppwrapWidgetRow">
|
|
64
|
+
<LinearLayout android:id="@+id/tile4" style="@style/AppwrapWidgetTile">
|
|
65
|
+
<FrameLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginBottom="4dp">
|
|
66
|
+
<ImageView android:id="@+id/tile4_icon" style="@style/AppwrapWidgetIcon" />
|
|
67
|
+
<TextView android:id="@+id/tile4_badge" style="@style/AppwrapWidgetBadge" android:visibility="gone" />
|
|
68
|
+
</FrameLayout>
|
|
69
|
+
<TextView android:id="@+id/tile4_label" style="@style/AppwrapWidgetLabel" />
|
|
70
|
+
</LinearLayout>
|
|
71
|
+
<LinearLayout android:id="@+id/tile5" style="@style/AppwrapWidgetTile">
|
|
72
|
+
<FrameLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginBottom="4dp">
|
|
73
|
+
<ImageView android:id="@+id/tile5_icon" style="@style/AppwrapWidgetIcon" />
|
|
74
|
+
<TextView android:id="@+id/tile5_badge" style="@style/AppwrapWidgetBadge" android:visibility="gone" />
|
|
75
|
+
</FrameLayout>
|
|
76
|
+
<TextView android:id="@+id/tile5_label" style="@style/AppwrapWidgetLabel" />
|
|
77
|
+
</LinearLayout>
|
|
78
|
+
<LinearLayout android:id="@+id/tile6" style="@style/AppwrapWidgetTile">
|
|
79
|
+
<FrameLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginBottom="4dp">
|
|
80
|
+
<ImageView android:id="@+id/tile6_icon" style="@style/AppwrapWidgetIcon" />
|
|
81
|
+
<TextView android:id="@+id/tile6_badge" style="@style/AppwrapWidgetBadge" android:visibility="gone" />
|
|
82
|
+
</FrameLayout>
|
|
83
|
+
<TextView android:id="@+id/tile6_label" style="@style/AppwrapWidgetLabel" />
|
|
84
|
+
</LinearLayout>
|
|
85
|
+
<LinearLayout android:id="@+id/tile7" style="@style/AppwrapWidgetTile">
|
|
86
|
+
<FrameLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginBottom="4dp">
|
|
87
|
+
<ImageView android:id="@+id/tile7_icon" style="@style/AppwrapWidgetIcon" />
|
|
88
|
+
<TextView android:id="@+id/tile7_badge" style="@style/AppwrapWidgetBadge" android:visibility="gone" />
|
|
89
|
+
</FrameLayout>
|
|
90
|
+
<TextView android:id="@+id/tile7_label" style="@style/AppwrapWidgetLabel" />
|
|
91
|
+
</LinearLayout>
|
|
92
|
+
</LinearLayout>
|
|
93
|
+
<LinearLayout android:id="@+id/row2" style="@style/AppwrapWidgetRow">
|
|
94
|
+
<LinearLayout android:id="@+id/tile8" style="@style/AppwrapWidgetTile">
|
|
95
|
+
<FrameLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginBottom="4dp">
|
|
96
|
+
<ImageView android:id="@+id/tile8_icon" style="@style/AppwrapWidgetIcon" />
|
|
97
|
+
<TextView android:id="@+id/tile8_badge" style="@style/AppwrapWidgetBadge" android:visibility="gone" />
|
|
98
|
+
</FrameLayout>
|
|
99
|
+
<TextView android:id="@+id/tile8_label" style="@style/AppwrapWidgetLabel" />
|
|
100
|
+
</LinearLayout>
|
|
101
|
+
<LinearLayout android:id="@+id/tile9" style="@style/AppwrapWidgetTile">
|
|
102
|
+
<FrameLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginBottom="4dp">
|
|
103
|
+
<ImageView android:id="@+id/tile9_icon" style="@style/AppwrapWidgetIcon" />
|
|
104
|
+
<TextView android:id="@+id/tile9_badge" style="@style/AppwrapWidgetBadge" android:visibility="gone" />
|
|
105
|
+
</FrameLayout>
|
|
106
|
+
<TextView android:id="@+id/tile9_label" style="@style/AppwrapWidgetLabel" />
|
|
107
|
+
</LinearLayout>
|
|
108
|
+
<LinearLayout android:id="@+id/tile10" style="@style/AppwrapWidgetTile">
|
|
109
|
+
<FrameLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginBottom="4dp">
|
|
110
|
+
<ImageView android:id="@+id/tile10_icon" style="@style/AppwrapWidgetIcon" />
|
|
111
|
+
<TextView android:id="@+id/tile10_badge" style="@style/AppwrapWidgetBadge" android:visibility="gone" />
|
|
112
|
+
</FrameLayout>
|
|
113
|
+
<TextView android:id="@+id/tile10_label" style="@style/AppwrapWidgetLabel" />
|
|
114
|
+
</LinearLayout>
|
|
115
|
+
<LinearLayout android:id="@+id/tile11" style="@style/AppwrapWidgetTile">
|
|
116
|
+
<FrameLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginBottom="4dp">
|
|
117
|
+
<ImageView android:id="@+id/tile11_icon" style="@style/AppwrapWidgetIcon" />
|
|
118
|
+
<TextView android:id="@+id/tile11_badge" style="@style/AppwrapWidgetBadge" android:visibility="gone" />
|
|
119
|
+
</FrameLayout>
|
|
120
|
+
<TextView android:id="@+id/tile11_label" style="@style/AppwrapWidgetLabel" />
|
|
121
|
+
</LinearLayout>
|
|
122
|
+
</LinearLayout>
|
|
70
123
|
</LinearLayout>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="utf-8"?>
|
|
2
|
+
<!-- Widget chrome colors. Split into values/ + values-night/ so the tile follows the host's theme
|
|
3
|
+
(iOS parity: containerBackground(.fill.tertiary) + .primary label adapt automatically; a
|
|
4
|
+
hardcoded white slab with #333 text stayed stark-white on a dark home screen). The widget host
|
|
5
|
+
inflates RemoteViews with its own configuration, so -night resolves without any provider code. -->
|
|
6
|
+
<resources>
|
|
7
|
+
<color name="appwrap_widget_bg">#F2FFFFFF</color>
|
|
8
|
+
<color name="appwrap_widget_title">#222222</color>
|
|
9
|
+
<color name="appwrap_widget_label">#333333</color>
|
|
10
|
+
</resources>
|
|
@@ -1,27 +1,54 @@
|
|
|
1
1
|
<?xml version="1.0" encoding="utf-8"?>
|
|
2
2
|
<resources>
|
|
3
|
-
<!-- One
|
|
3
|
+
<!-- One grid row: 4 equal columns, CONTENT-HEIGHT. The absence of a layout_weight is load-bearing
|
|
4
|
+
(see the layout's contract comment) — a weighted row splits the widget height and floats its
|
|
5
|
+
icons mid-band, leaving a dead gap on a tall widget. -->
|
|
6
|
+
<style name="AppwrapWidgetRow">
|
|
7
|
+
<item name="android:layout_width">match_parent</item>
|
|
8
|
+
<item name="android:layout_height">wrap_content</item>
|
|
9
|
+
<item name="android:orientation">horizontal</item>
|
|
10
|
+
<item name="android:weightSum">4</item>
|
|
11
|
+
<item name="android:layout_marginBottom">10dp</item>
|
|
12
|
+
</style>
|
|
13
|
+
<!-- One icon-grid tile: a centered icon over a single-line label, equal-weight in its row.
|
|
14
|
+
Height is wrap_content so the row measures to content, not to the widget's full height. -->
|
|
4
15
|
<style name="AppwrapWidgetTile">
|
|
5
16
|
<item name="android:layout_width">0dp</item>
|
|
6
|
-
<item name="android:layout_height">
|
|
17
|
+
<item name="android:layout_height">wrap_content</item>
|
|
7
18
|
<item name="android:layout_weight">1</item>
|
|
8
19
|
<item name="android:orientation">vertical</item>
|
|
9
|
-
<item name="android:gravity">
|
|
20
|
+
<item name="android:gravity">center_horizontal</item>
|
|
10
21
|
<item name="android:padding">4dp</item>
|
|
11
22
|
</style>
|
|
23
|
+
<!-- The provider rounds the bitmap itself (RemoteViews ImageView can't clip via outline), so the
|
|
24
|
+
source is drawn crisp at full res and just scaled to fit here — no fitCenter squashing. -->
|
|
25
|
+
<!-- The icon carries a top margin + SYMMETRIC start/end margins so the wrap_content badge-container
|
|
26
|
+
FrameLayout measures LARGER than the 44dp icon. The badge (pinned top|end of that padded
|
|
27
|
+
container) lands in the end-margin region — visually BEYOND the icon's top-right corner (iOS
|
|
28
|
+
offset(x:6,y:-6) parity) — yet stays WITHIN the container bounds, so RemoteViews' default
|
|
29
|
+
child-clipping never cuts it. marginStart mirrors marginEnd so the icon stays HORIZONTALLY
|
|
30
|
+
CENTERED within the FrameLayout (and thus under its label) — an end-only margin shifts the
|
|
31
|
+
icon left, leaving an asymmetric right gap.
|
|
32
|
+
SIZE: 44dp + 14dp of margins = 58dp, + 8dp tile padding = 66dp per column. At 4 columns that
|
|
33
|
+
needs ~284dp of widget width, which is why appwrap_widget_info declares minResizeWidth 280dp
|
|
34
|
+
— shrinking below that would start clipping the icons. -->
|
|
12
35
|
<style name="AppwrapWidgetIcon">
|
|
13
|
-
<item name="android:layout_width">
|
|
14
|
-
<item name="android:layout_height">
|
|
15
|
-
<item name="android:
|
|
36
|
+
<item name="android:layout_width">44dp</item>
|
|
37
|
+
<item name="android:layout_height">44dp</item>
|
|
38
|
+
<item name="android:layout_marginTop">7dp</item>
|
|
39
|
+
<item name="android:layout_marginStart">7dp</item>
|
|
40
|
+
<item name="android:layout_marginEnd">7dp</item>
|
|
41
|
+
<item name="android:scaleType">fitXY</item>
|
|
16
42
|
</style>
|
|
17
|
-
<!-- Unread-count bubble
|
|
43
|
+
<!-- Unread-count bubble overlapping the icon's top-right corner (iOS-parity: small, circular for
|
|
44
|
+
single digits, red fill, white ring, bold white number). ~40% of the 44dp icon. -->
|
|
18
45
|
<style name="AppwrapWidgetBadge">
|
|
19
46
|
<item name="android:layout_width">wrap_content</item>
|
|
20
|
-
<item name="android:layout_height">
|
|
47
|
+
<item name="android:layout_height">17dp</item>
|
|
21
48
|
<item name="android:layout_gravity">top|end</item>
|
|
22
|
-
<item name="android:minWidth">
|
|
23
|
-
<item name="android:paddingLeft">
|
|
24
|
-
<item name="android:paddingRight">
|
|
49
|
+
<item name="android:minWidth">17dp</item>
|
|
50
|
+
<item name="android:paddingLeft">4dp</item>
|
|
51
|
+
<item name="android:paddingRight">4dp</item>
|
|
25
52
|
<item name="android:background">@drawable/appwrap_badge_bg</item>
|
|
26
53
|
<item name="android:gravity">center</item>
|
|
27
54
|
<item name="android:textColor">#FFFFFF</item>
|
|
@@ -32,7 +59,7 @@
|
|
|
32
59
|
<style name="AppwrapWidgetLabel">
|
|
33
60
|
<item name="android:layout_width">wrap_content</item>
|
|
34
61
|
<item name="android:layout_height">wrap_content</item>
|
|
35
|
-
<item name="android:textColor"
|
|
62
|
+
<item name="android:textColor">@color/appwrap_widget_label</item>
|
|
36
63
|
<item name="android:textSize">11sp</item>
|
|
37
64
|
<item name="android:maxLines">1</item>
|
|
38
65
|
<item name="android:ellipsize">end</item>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="utf-8"?>
|
|
2
|
+
<!-- Dark-theme counterpart of values/appwrap_widget_colors.xml (same names, resolved by the host's
|
|
3
|
+
night configuration). Translucent near-black slab + light text, mirroring the light variant's
|
|
4
|
+
alpha so the tile reads as the same material on either theme. -->
|
|
5
|
+
<resources>
|
|
6
|
+
<color name="appwrap_widget_bg">#E61C1C1E</color>
|
|
7
|
+
<color name="appwrap_widget_title">#FFFFFF</color>
|
|
8
|
+
<color name="appwrap_widget_label">#E6FFFFFF</color>
|
|
9
|
+
</resources>
|
package/runtime/modules-native/widget/App_Resources/Android/src/main/res/xml/appwrap_widget_info.xml
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
<?xml version="1.0" encoding="utf-8"?>
|
|
2
2
|
<!-- Launcher widget: ~4×2 cells (medium), resizable, initial layout = the icon-grid. updatePeriodMillis
|
|
3
|
-
0 → the app drives refreshes itself via widget.publish (AppWidgetManager broadcast).
|
|
3
|
+
0 → the app drives refreshes itself via widget.publish (AppWidgetManager broadcast).
|
|
4
|
+
minResizeWidth 280dp: the grid is 4 fixed-width columns (~66dp each — see AppwrapWidgetIcon),
|
|
5
|
+
so ~284dp is the narrowest width that renders without clipping the icons. minWidth stays 250dp
|
|
6
|
+
(it only gates where the widget may be PLACED); minResizeWidth is what bounds a user drag. -->
|
|
4
7
|
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
|
5
8
|
android:minWidth="250dp"
|
|
6
9
|
android:minHeight="110dp"
|
|
10
|
+
android:minResizeWidth="280dp"
|
|
7
11
|
android:targetCellWidth="4"
|
|
8
12
|
android:targetCellHeight="2"
|
|
9
13
|
android:updatePeriodMillis="0"
|