perf(widget): cut expand/collapse tap latency (native patches + light headless path)

- Upgrade react-native-android-widget 0.20.1 -> 0.21.0 (expedited
  WorkManager upstream, clickable-area ripple corner fix)
- patch-package on the lib:
  1. WIDGET_CLICK bypasses WorkManager and runs the headless JS task
     directly on the ReactHost (goAsync + WorkManager fallback), removing
     two Room writes + worker dispatch from every tap and WorkManager
     init from the cold path
  2. WEBP_LOSSLESS (effort 0) encoding instead of PNG q100 on API 30+,
     several times faster for widget-sized bitmaps
  3. Copy identical light/dark collection item files instead of
     compressing every row bitmap twice when the config is single-mode
- Extract dependency-light src/services/widgetState.ts so the headless
  click handler no longer pulls DB/drizzle/date-fns at cold start
- Smoke tests: lock the light import graph + patch/postinstall wiring
- Bump v1.6.5 (versionCode 17) for the preview build

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-07-19 16:41:47 -04:00
parent 9ccc9f4a9d
commit fccd7faf95
10 changed files with 583 additions and 84 deletions

View file

@ -132,8 +132,19 @@ Couleurs sombres : fond `#1A1A1A`, surface `#2A2A2A`, bordure `#3A3A3A`, texte `
- `widgetSync.ts` lit les tâches depuis SQLite et les cache dans AsyncStorage (`widget:state`)
- Le thème est lu depuis AsyncStorage (`simpl-liste-settings` → `state.theme`), résolu si `system` via `Appearance.getColorScheme()`
- `widgetTaskHandler.ts` gère le rendu headless (quand l'app n'est pas ouverte) en lisant la clé consolidée `widget:state`
- `widgetState.ts` contient l'état widget + accesseurs AsyncStorage. Module volontairement léger (pas de DB/drizzle/date-fns) : c'est le chemin critique du cold start headless. Le smoke test verrouille son graphe d'import.
- Les couleurs du widget suivent la même palette que l'app (voir `LIGHT_COLORS` / `DARK_COLORS` dans `TaskListWidget.tsx`)
- Un debounce de 2s sur `TOGGLE_EXPAND` empêche les double-taps d'annuler l'expansion
- Un debounce de 600ms sur `TOGGLE_EXPAND` empêche les double-taps d'annuler l'expansion
### Latence des interactions (expand/collapse, toggle)
Chemin d'un tap : broadcast → `RNWidgetProvider` → task headless JS → `renderWidget` → bitmap → RemoteViews. Optimisations en place :
- **Render optimiste** dans `widgetTaskHandler.ts` : `renderWidget` avant la persistance AsyncStorage et le write DB
- **Patchs natifs** (`patches/react-native-android-widget+<version>.patch`, appliqués par `postinstall` → patch-package, donc aussi en build EAS) :
1. Les `WIDGET_CLICK` court-circuitent WorkManager (`RNWidgetClickTask.java` : exécution directe sur le ReactHost via goAsync, fallback WorkManager). Les autres actions (UPDATE/ADDED/RESIZED) gardent WorkManager expedited (upstream 0.21.0)
2. Compression **WEBP_LOSSLESS effort 0** au lieu de PNG q100 (API 30+) dans `RNWidgetImageProvider.writeImage`
3. Dédup light/dark des items de collection quand la config est unique : copie de fichier au lieu d'une seconde compression
- En cas d'upgrade de la lib : régénérer le patch (`npx patch-package react-native-android-widget`) après avoir réappliqué les 3 modifications ; le postinstall échoue bruyamment si le patch ne s'applique plus
- **Mesurer** (build dev sur appareil) : `adb logcat -s ReactNativeJS | grep '\[widget\]'` — chaque étape du handler est instrumentée en `__DEV__`. Le plancher incompressible reste le cold start du runtime JS headless (~0,5-1,5s si le process est mort) ; à chaud, viser < 300ms
### Clés AsyncStorage utilisées par le widget
| Clé | Contenu |

View file

@ -2,7 +2,7 @@
"expo": {
"name": "Simpl-Liste",
"slug": "simpl-liste",
"version": "1.6.4",
"version": "1.6.5",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "simplliste",
@ -24,7 +24,7 @@
"backgroundColor": "#FFF8F0"
},
"edgeToEdgeEnabled": true,
"versionCode": 16
"versionCode": 17
},
"plugins": [
"expo-router",

195
package-lock.json generated
View file

@ -1,12 +1,13 @@
{
"name": "simpl-liste",
"version": "1.6.4",
"version": "1.6.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "simpl-liste",
"version": "1.6.4",
"version": "1.6.5",
"hasInstallScript": true,
"dependencies": {
"@expo-google-fonts/inter": "^0.4.2",
"@expo/ngrok": "^4.1.3",
@ -43,7 +44,7 @@
"react-dom": "19.1.0",
"react-i18next": "^16.5.4",
"react-native": "0.81.5",
"react-native-android-widget": "^0.20.1",
"react-native-android-widget": "^0.21.0",
"react-native-draggable-flatlist": "^4.0.3",
"react-native-gesture-handler": "~2.28.0",
"react-native-keyboard-controller": "1.18.5",
@ -58,6 +59,7 @@
"devDependencies": {
"@types/react": "~19.1.0",
"drizzle-kit": "^0.31.9",
"patch-package": "^8.0.1",
"react-test-renderer": "19.1.0",
"tailwindcss": "^3.4.17",
"typescript": "~5.9.2"
@ -3851,6 +3853,13 @@
"node": ">=10.0.0"
}
},
"node_modules/@yarnpkg/lockfile": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz",
"integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==",
"dev": true,
"license": "BSD-2-Clause"
},
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
@ -6674,6 +6683,16 @@
"node": ">=8"
}
},
"node_modules/find-yarn-workspace-root": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz",
"integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"micromatch": "^4.0.2"
}
},
"node_modules/flow-enums-runtime": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz",
@ -6719,6 +6738,21 @@
"node": ">= 0.6"
}
},
"node_modules/fs-extra": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@ -7464,6 +7498,13 @@
"node": ">=8"
}
},
"node_modules/isarray": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
"integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
"dev": true,
"license": "MIT"
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@ -7728,6 +7769,26 @@
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
"license": "MIT"
},
"node_modules/json-stable-stringify": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz",
"integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.8",
"call-bound": "^1.0.4",
"isarray": "^2.0.5",
"jsonify": "^0.0.1",
"object-keys": "^1.1.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@ -7740,6 +7801,29 @@
"node": ">=6"
}
},
"node_modules/jsonfile": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
"integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/jsonify": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz",
"integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==",
"dev": true,
"license": "Public Domain",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@ -7749,6 +7833,16 @@
"json-buffer": "3.0.1"
}
},
"node_modules/klaw-sync": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz",
"integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.1.11"
}
},
"node_modules/kleur": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
@ -9229,6 +9323,75 @@
"node": ">= 0.8"
}
},
"node_modules/patch-package": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz",
"integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@yarnpkg/lockfile": "^1.1.0",
"chalk": "^4.1.2",
"ci-info": "^3.7.0",
"cross-spawn": "^7.0.3",
"find-yarn-workspace-root": "^2.0.0",
"fs-extra": "^10.0.0",
"json-stable-stringify": "^1.0.2",
"klaw-sync": "^6.0.0",
"minimist": "^1.2.6",
"open": "^7.4.2",
"semver": "^7.5.3",
"slash": "^2.0.0",
"tmp": "^0.2.4",
"yaml": "^2.2.2"
},
"bin": {
"patch-package": "index.js"
},
"engines": {
"node": ">=14",
"npm": ">5"
}
},
"node_modules/patch-package/node_modules/ci-info": {
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz",
"integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/sibiraj-s"
}
],
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/patch-package/node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/patch-package/node_modules/slash": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz",
"integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@ -9859,9 +10022,9 @@
}
},
"node_modules/react-native-android-widget": {
"version": "0.20.1",
"resolved": "https://registry.npmjs.org/react-native-android-widget/-/react-native-android-widget-0.20.1.tgz",
"integrity": "sha512-m3akQCyoG6XUH8OLHOyL3/Xxx/XXTXf9ZyFYmWvSQrv1YUaZ7kVjmeLIYnaNz+qQ9VrTAS9OygCxZQptqCGzjQ==",
"version": "0.21.0",
"resolved": "https://registry.npmjs.org/react-native-android-widget/-/react-native-android-widget-0.21.0.tgz",
"integrity": "sha512-0HkXYvsXmzEUHts/nBG+lLajgypDOerht7OvDhoAsFzJ+jt6tY8m+wNK/Ng+1cubX+ZEfpBedpPPFe8uBZD8Rg==",
"license": "MIT",
"peerDependencies": {
"expo": ">=54.0.0",
@ -11630,6 +11793,16 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/tmp": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
"integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.14"
}
},
"node_modules/tmpl": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
@ -11800,6 +11973,16 @@
"node": ">=8"
}
},
"node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",

View file

@ -1,13 +1,14 @@
{
"name": "simpl-liste",
"main": "index.js",
"version": "1.6.4",
"version": "1.6.5",
"scripts": {
"start": "expo start",
"test": "node tests/smoke.test.cjs",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web"
"web": "expo start --web",
"postinstall": "patch-package"
},
"dependencies": {
"@expo-google-fonts/inter": "^0.4.2",
@ -45,7 +46,7 @@
"react-dom": "19.1.0",
"react-i18next": "^16.5.4",
"react-native": "0.81.5",
"react-native-android-widget": "^0.20.1",
"react-native-android-widget": "^0.21.0",
"react-native-draggable-flatlist": "^4.0.3",
"react-native-gesture-handler": "~2.28.0",
"react-native-keyboard-controller": "1.18.5",
@ -60,6 +61,7 @@
"devDependencies": {
"@types/react": "~19.1.0",
"drizzle-kit": "^0.31.9",
"patch-package": "^8.0.1",
"react-test-renderer": "19.1.0",
"tailwindcss": "^3.4.17",
"typescript": "~5.9.2"

View file

@ -0,0 +1,232 @@
diff --git a/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidget.java b/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidget.java
index a7079f9..470c469 100644
--- a/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidget.java
+++ b/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidget.java
@@ -107,7 +107,9 @@ public class RNWidget {
for (int i = 0; i < Math.min(collectionViews.size(), RNWidgetCollectionService.MAX_COLLECTION_WIDGETS); i++) {
CollectionView collectionView = collectionViews.get(i);
RNWidgetCollectionService.storeCollection(appContext, widgetId, i, collectionView.getRenderedViews(), "light");
- RNWidgetCollectionService.storeCollection(appContext, widgetId, i, collectionView.getRenderedViews(), "dark");
+ // PATCH (simpl-liste): both modes are identical here — copy the
+ // encoded files instead of compressing every bitmap twice.
+ RNWidgetCollectionService.copyCollection(appContext, widgetId, i, collectionView.getRenderedViews().size(), "light", "dark");
}
}
diff --git a/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetClickTask.java b/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetClickTask.java
new file mode 100644
index 0000000..10de86e
--- /dev/null
+++ b/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetClickTask.java
@@ -0,0 +1,109 @@
+package com.reactnativeandroidwidget;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.os.Handler;
+import android.os.Looper;
+
+import androidx.work.Data;
+
+import com.facebook.react.ReactApplication;
+import com.facebook.react.ReactHost;
+import com.facebook.react.ReactInstanceEventListener;
+import com.facebook.react.bridge.Arguments;
+import com.facebook.react.bridge.ReactContext;
+import com.facebook.react.bridge.UiThreadUtil;
+import com.facebook.react.jstasks.HeadlessJsTaskConfig;
+import com.facebook.react.jstasks.HeadlessJsTaskContext;
+import com.facebook.react.jstasks.HeadlessJsTaskEventListener;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * PATCH (simpl-liste): runs widget click tasks directly on the ReactHost instead
+ * of going through WorkManager. WorkManager adds scheduling latency to every tap
+ * (two Room DB writes + worker dispatch, plus full WorkManager init on a cold
+ * process). Clicks are user-facing and latency-critical, and fire-and-forget
+ * semantics are acceptable for them: the widget state self-heals from
+ * AsyncStorage on the next interaction if a click task is ever lost. Non-click
+ * paths (WIDGET_UPDATE/ADDED/RESIZED/DELETED) keep using WorkManager through
+ * RNWidgetJsCommunication.
+ */
+class RNWidgetClickTask {
+ // Manifest-registered receivers get ~10s before the system gives up on the
+ // broadcast; always finish before that even if the JS task hangs.
+ private static final long BROADCAST_TIMEOUT_MS = 9000;
+
+ static void start(Context context, BroadcastReceiver.PendingResult pendingResult, Data data) {
+ AtomicBoolean finished = new AtomicBoolean(false);
+ Runnable finishBroadcast = () -> {
+ if (finished.compareAndSet(false, true)) {
+ try {
+ pendingResult.finish();
+ } catch (Exception ignored) {
+ }
+ }
+ };
+ new Handler(Looper.getMainLooper()).postDelayed(finishBroadcast, BROADCAST_TIMEOUT_MS);
+
+ try {
+ ReactHost reactHost = ((ReactApplication) context.getApplicationContext()).getReactHost();
+ ReactContext reactContext = reactHost.getCurrentReactContext();
+
+ Map<String, Object> arguments = new HashMap<>(data.getKeyValueMap());
+ arguments.put("screenInfo", RNWidgetUtil.getScreenInfo(context.getApplicationContext()).toHashMap());
+
+ HeadlessJsTaskConfig taskConfig = new HeadlessJsTaskConfig(
+ "RNWidgetBackgroundTask",
+ Arguments.makeNativeMap(arguments),
+ 30 * 1000,
+ true
+ );
+
+ if (reactContext == null) {
+ reactHost.addReactInstanceEventListener(new ReactInstanceEventListener() {
+ @Override
+ public void onReactContextInitialized(ReactContext initializedContext) {
+ reactHost.removeReactInstanceEventListener(this);
+ runTask(initializedContext, taskConfig, finishBroadcast);
+ }
+ });
+ reactHost.start();
+ } else {
+ runTask(reactContext, taskConfig, finishBroadcast);
+ }
+ } catch (Throwable t) {
+ // Fall back to the WorkManager path so the click is never lost.
+ RNWidgetJsCommunication.startBackgroundTask(context, data);
+ finishBroadcast.run();
+ }
+ }
+
+ private static void runTask(ReactContext reactContext, HeadlessJsTaskConfig taskConfig, Runnable finishBroadcast) {
+ UiThreadUtil.runOnUiThread(() -> {
+ try {
+ HeadlessJsTaskContext headlessJsTaskContext = HeadlessJsTaskContext.getInstance(reactContext);
+ int[] taskIdHolder = new int[]{-1};
+ HeadlessJsTaskEventListener listener = new HeadlessJsTaskEventListener() {
+ @Override
+ public void onHeadlessJsTaskStart(int taskId) {
+ }
+
+ @Override
+ public void onHeadlessJsTaskFinish(int taskId) {
+ if (taskId == taskIdHolder[0]) {
+ headlessJsTaskContext.removeTaskEventListener(this);
+ finishBroadcast.run();
+ }
+ }
+ };
+ headlessJsTaskContext.addTaskEventListener(listener);
+ taskIdHolder[0] = headlessJsTaskContext.startTask(taskConfig);
+ } catch (Throwable t) {
+ finishBroadcast.run();
+ }
+ });
+ }
+}
diff --git a/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetCollectionService.java b/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetCollectionService.java
index f818d97..07432e8 100644
--- a/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetCollectionService.java
+++ b/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetCollectionService.java
@@ -32,6 +32,20 @@ public class RNWidgetCollectionService extends RemoteViewsService {
}
}
+ // PATCH (simpl-liste): when the widget has a single (light/dark-agnostic)
+ // config, both modes show identical item images. Copying the already-encoded
+ // files is much cheaper than compressing every item bitmap a second time.
+ public static void copyCollection(ReactApplicationContext context, int widgetId, int collectionId, int itemCount, String fromMode, String toMode) {
+ RNWidgetImageProvider.deleteCollectionImages(context, widgetId, collectionId, toMode);
+ for (int i = 0; i < itemCount; i++) {
+ RNWidgetImageProvider.copyImage(
+ context,
+ getImageName(widgetId, collectionId, i, fromMode),
+ getImageName(widgetId, collectionId, i, toMode)
+ );
+ }
+ }
+
static String getImageName(int widgetId, int collectionId, int position, String mode) {
return "widget_" + widgetId + "_mode_" + mode + "_collection_" + collectionId + "_" + position + ".png";
}
diff --git a/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetImageProvider.java b/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetImageProvider.java
index 0f963f0..ae18c25 100644
--- a/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetImageProvider.java
+++ b/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetImageProvider.java
@@ -6,11 +6,13 @@ import android.content.Context;
import android.database.Cursor;
import android.graphics.Bitmap;
import android.net.Uri;
+import android.os.Build;
import android.os.ParcelFileDescriptor;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.io.File;
+import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
@@ -94,7 +96,15 @@ public class RNWidgetImageProvider extends ContentProvider {
FileOutputStream fos = null;
try {
fos = new FileOutputStream(file);
- bitmap.compress(Bitmap.CompressFormat.PNG, 100, fos);
+ // PATCH (simpl-liste): WEBP_LOSSLESS at minimum effort encodes several
+ // times faster than PNG for widget-sized bitmaps, which cuts
+ // tap-to-render latency. Consumers decode by magic bytes, so the .png
+ // file name and the provider's image/png MIME type are not a problem.
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ bitmap.compress(Bitmap.CompressFormat.WEBP_LOSSLESS, 0, fos);
+ } else {
+ bitmap.compress(Bitmap.CompressFormat.PNG, 100, fos);
+ }
} catch (Exception e) {
e.printStackTrace();
} finally {
@@ -108,6 +118,26 @@ public class RNWidgetImageProvider extends ContentProvider {
}
}
+ // PATCH (simpl-liste): copy an already-encoded image instead of re-compressing
+ // the same bitmap a second time (used for the dark variant when the widget has
+ // a single light/dark-agnostic config).
+ static void copyImage(Context context, String fromName, String toName) {
+ File folder = getFolderWithImages(context);
+ File from = new File(folder, fromName);
+ File to = new File(folder, toName);
+
+ try (FileInputStream in = new FileInputStream(from);
+ FileOutputStream out = new FileOutputStream(to)) {
+ byte[] buffer = new byte[8192];
+ int read;
+ while ((read = in.read(buffer)) != -1) {
+ out.write(buffer, 0, read);
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ }
+
static Uri getImageUri(Context context, String fileName) {
String authority = context.getPackageName() + AUTHORITY_SUFFIX;
diff --git a/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetProvider.java b/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetProvider.java
index 647d8f4..043c56c 100644
--- a/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetProvider.java
+++ b/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetProvider.java
@@ -151,6 +151,9 @@ public class RNWidgetProvider extends AppWidgetProvider {
}
Data data = RNWidgetJsCommunication.buildData(context, getClass().getSimpleName(), widgetId, "WIDGET_CLICK", additionalData);
- RNWidgetJsCommunication.startBackgroundTask(context, data);
+ // PATCH (simpl-liste): user-initiated clicks bypass WorkManager and run the
+ // headless JS task directly for latency. goAsync() keeps the process alive
+ // until the task reports completion (or the safety timeout hits).
+ RNWidgetClickTask.start(context, goAsync(), data);
}
}

View file

@ -0,0 +1,78 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
// Consolidated widget state stored under a single AsyncStorage key.
//
// This module is on the critical path of the headless widget click handler:
// it must stay dependency-light (no DB, no drizzle, no date-fns) so that a
// cold-started headless task executes as little module code as possible.
export const WIDGET_STATE_KEY = 'widget:state';
export const WIDGET_NAMES = ['SimplListeSmall', 'SimplListeMedium', 'SimplListeLarge'] as const;
// Legacy keys — used for migration only
const LEGACY_DATA_KEY = 'widget:tasks';
const LEGACY_DARK_KEY = 'widget:isDark';
const LEGACY_EXPANDED_KEY = 'widget:expandedTaskIds';
export interface WidgetSubtask {
id: string;
title: string;
completed: boolean;
}
export interface WidgetTask {
id: string;
title: string;
priority: number;
dueDate: string | null;
completed: boolean;
listColor: string | null;
subtaskCount: number;
subtaskDoneCount: number;
subtasks: WidgetSubtask[];
}
export interface WidgetState {
tasks: WidgetTask[];
isDark: boolean;
expandedTaskIds: string[];
}
export async function getWidgetState(): Promise<WidgetState> {
try {
const raw = await AsyncStorage.getItem(WIDGET_STATE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
return {
tasks: Array.isArray(parsed.tasks) ? parsed.tasks : [],
isDark: parsed.isDark === true,
expandedTaskIds: Array.isArray(parsed.expandedTaskIds) ? parsed.expandedTaskIds : [],
};
}
// Migration from legacy keys
const [dataRaw, darkRaw, expandedRaw] = await Promise.all([
AsyncStorage.getItem(LEGACY_DATA_KEY),
AsyncStorage.getItem(LEGACY_DARK_KEY),
AsyncStorage.getItem(LEGACY_EXPANDED_KEY),
]);
const state: WidgetState = {
tasks: dataRaw ? JSON.parse(dataRaw) : [],
isDark: darkRaw ? JSON.parse(darkRaw) === true : false,
expandedTaskIds: expandedRaw ? JSON.parse(expandedRaw) : [],
};
// Write consolidated key and clean up legacy keys
await AsyncStorage.setItem(WIDGET_STATE_KEY, JSON.stringify(state));
await AsyncStorage.multiRemove([LEGACY_DATA_KEY, LEGACY_DARK_KEY, LEGACY_EXPANDED_KEY]);
return state;
} catch {
return { tasks: [], isDark: false, expandedTaskIds: [] };
}
}
export async function setWidgetState(state: WidgetState): Promise<void> {
await AsyncStorage.setItem(WIDGET_STATE_KEY, JSON.stringify(state));
}

View file

@ -6,77 +6,17 @@ import { tasks, lists } from '../db/schema';
import { eq, and, isNull, gte, lte, lt, asc, sql } from 'drizzle-orm';
import { startOfDay, endOfDay, addWeeks } from 'date-fns';
import { TaskListWidget } from '../widgets/TaskListWidget';
import {
getWidgetState,
setWidgetState,
WIDGET_NAMES,
type WidgetState,
type WidgetTask,
} from './widgetState';
export const WIDGET_STATE_KEY = 'widget:state';
export const WIDGET_NAMES = ['SimplListeSmall', 'SimplListeMedium', 'SimplListeLarge'] as const;
// Legacy keys — used for migration only
const LEGACY_DATA_KEY = 'widget:tasks';
const LEGACY_DARK_KEY = 'widget:isDark';
const LEGACY_EXPANDED_KEY = 'widget:expandedTaskIds';
export interface WidgetSubtask {
id: string;
title: string;
completed: boolean;
}
export interface WidgetTask {
id: string;
title: string;
priority: number;
dueDate: string | null;
completed: boolean;
listColor: string | null;
subtaskCount: number;
subtaskDoneCount: number;
subtasks: WidgetSubtask[];
}
export interface WidgetState {
tasks: WidgetTask[];
isDark: boolean;
expandedTaskIds: string[];
}
export async function getWidgetState(): Promise<WidgetState> {
try {
const raw = await AsyncStorage.getItem(WIDGET_STATE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
return {
tasks: Array.isArray(parsed.tasks) ? parsed.tasks : [],
isDark: parsed.isDark === true,
expandedTaskIds: Array.isArray(parsed.expandedTaskIds) ? parsed.expandedTaskIds : [],
};
}
// Migration from legacy keys
const [dataRaw, darkRaw, expandedRaw] = await Promise.all([
AsyncStorage.getItem(LEGACY_DATA_KEY),
AsyncStorage.getItem(LEGACY_DARK_KEY),
AsyncStorage.getItem(LEGACY_EXPANDED_KEY),
]);
const state: WidgetState = {
tasks: dataRaw ? JSON.parse(dataRaw) : [],
isDark: darkRaw ? JSON.parse(darkRaw) === true : false,
expandedTaskIds: expandedRaw ? JSON.parse(expandedRaw) : [],
};
// Write consolidated key and clean up legacy keys
await AsyncStorage.setItem(WIDGET_STATE_KEY, JSON.stringify(state));
await AsyncStorage.multiRemove([LEGACY_DATA_KEY, LEGACY_DARK_KEY, LEGACY_EXPANDED_KEY]);
return state;
} catch {
return { tasks: [], isDark: false, expandedTaskIds: [] };
}
}
export async function setWidgetState(state: WidgetState): Promise<void> {
await AsyncStorage.setItem(WIDGET_STATE_KEY, JSON.stringify(state));
}
// State accessors and types live in widgetState.ts (dependency-light module
// used by the headless click handler); re-exported here for existing callers.
export * from './widgetState';
export async function syncWidgetData(): Promise<void> {
if (Platform.OS !== 'android') return;

View file

@ -4,7 +4,7 @@ import type { WidgetInfo } from 'react-native-android-widget';
type HexColor = `#${string}`;
type ColorProp = HexColor;
import type { WidgetTask, WidgetSubtask } from '../services/widgetSync';
import type { WidgetTask, WidgetSubtask } from '../services/widgetState';
import {
isToday,
isTomorrow,

View file

@ -1,7 +1,7 @@
import type { WidgetTaskHandlerProps } from 'react-native-android-widget';
import { requestWidgetUpdate } from 'react-native-android-widget';
import { TaskListWidget } from './TaskListWidget';
import { getWidgetState, setWidgetState, WIDGET_NAMES, type WidgetTask } from '../services/widgetSync';
import { getWidgetState, setWidgetState, WIDGET_NAMES, type WidgetTask } from '../services/widgetState';
import { isValidUUID } from '../lib/validation';
const EXPAND_DEBOUNCE_MS = 600;

View file

@ -76,6 +76,59 @@ check('uuid v5 with buffer arg fills buffer (vuln site)', () => {
);
});
// --- Widget latency guards ---
// The headless widget click handler must stay on a dependency-light import
// path: a cold-started headless task executes every statically imported
// module before the first render, so heavy imports directly translate into
// tap-to-feedback latency on the home screen widget.
const fs = require('node:fs');
const path = require('node:path');
function staticImportsOf(file) {
const src = fs.readFileSync(path.join(__dirname, '..', file), 'utf8');
const imports = [];
const re = /^import\s[^;]*?from\s+['"]([^'"]+)['"]/gms;
let m;
while ((m = re.exec(src)) !== null) imports.push(m[1]);
return imports;
}
check('widgetState.ts import graph stays light (no db/drizzle/date-fns)', () => {
const imports = staticImportsOf('src/services/widgetState.ts');
const heavy = imports.filter(
(i) => /drizzle|date-fns|\/db\/|expo-sqlite/.test(i)
);
assert.deepEqual(heavy, [], `heavy imports found: ${heavy.join(', ')}`);
});
check('widgetTaskHandler.ts does not statically import widgetSync (DB path)', () => {
const imports = staticImportsOf('src/widgets/widgetTaskHandler.ts');
const offenders = imports.filter((i) => /widgetSync|\/db\/|drizzle/.test(i));
assert.deepEqual(
offenders,
[],
`widgetTaskHandler must reach the DB via dynamic import only, found: ${offenders.join(', ')}`
);
});
// The native latency patches (WorkManager bypass on click, WEBP compression,
// light/dark collection dedup) live in patches/ and are re-applied by the
// postinstall hook. Without either piece, an EAS build silently ships the
// unpatched (slow) widget pipeline.
check('react-native-android-widget latency patch is wired up', () => {
const pkg = require('../package.json');
const version = pkg.dependencies['react-native-android-widget'].replace(/^[~^]/, '');
const patchFile = path.join(
__dirname,
'..',
'patches',
`react-native-android-widget+${version}.patch`
);
assert.ok(fs.existsSync(patchFile), `missing ${patchFile}`);
assert.equal(pkg.scripts.postinstall, 'patch-package', 'postinstall must run patch-package');
});
if (failed === 0) {
console.log('\nsmoke OK');
process.exit(0);