@livx.cc/appwrap 0.51.0 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livx.cc/appwrap",
3
- "version": "0.51.0",
3
+ "version": "0.51.1",
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",
@@ -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
- // Drop the retained clients now that the native view is going away, so their peers can be GC'd.
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
@@ -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 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.
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 tiles = listOf("tile0", "tile1", "tile2", "tile3")
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 < entries.length()) entries.optJSONObject(i) else null
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()) {
@@ -1,32 +1,36 @@
1
1
  <?xml version="1.0" encoding="utf-8"?>
2
- <!-- AppWrap launcher widget: title + a 2×2 icon-grid of deep-linked tiles. Populated by
3
- AppwrapWidgetProvider from the published payload; empty tiles are set INVISIBLE. Each icon can
4
- carry an unread-count badge (tileN_badge) overlaid on its top-right corner. -->
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="#F2FFFFFF"
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="#222222"
26
+ android:textColor="@color/appwrap_widget_title"
17
27
  android:textStyle="bold"
18
28
  android:textSize="12sp"
19
- android:layout_marginBottom="6dp"
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 icon-grid tile: a centered icon over a single-line label, equal-weight in its row. -->
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">match_parent</item>
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">center</item>
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">36dp</item>
14
- <item name="android:layout_height">36dp</item>
15
- <item name="android:scaleType">fitCenter</item>
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 overlaid on the icon's top-right corner. -->
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">18dp</item>
47
+ <item name="android:layout_height">17dp</item>
21
48
  <item name="android:layout_gravity">top|end</item>
22
- <item name="android:minWidth">18dp</item>
23
- <item name="android:paddingLeft">5dp</item>
24
- <item name="android:paddingRight">5dp</item>
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">#333333</item>
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>
@@ -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"