From fccd7faf95c29fa630b27dfa816353ef9c9bae31 Mon Sep 17 00:00:00 2001 From: le king fu Date: Sun, 19 Jul 2026 16:41:47 -0400 Subject: [PATCH] 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 --- CLAUDE.md | 13 +- app.json | 4 +- package-lock.json | 195 ++++++++++++++- package.json | 8 +- .../react-native-android-widget+0.21.0.patch | 232 ++++++++++++++++++ src/services/widgetState.ts | 78 ++++++ src/services/widgetSync.ts | 80 +----- src/widgets/TaskListWidget.tsx | 2 +- src/widgets/widgetTaskHandler.ts | 2 +- tests/smoke.test.cjs | 53 ++++ 10 files changed, 583 insertions(+), 84 deletions(-) create mode 100644 patches/react-native-android-widget+0.21.0.patch create mode 100644 src/services/widgetState.ts diff --git a/CLAUDE.md b/CLAUDE.md index 2a79da1..a06a0a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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+.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 | diff --git a/app.json b/app.json index 5617251..88084a5 100644 --- a/app.json +++ b/app.json @@ -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", diff --git a/package-lock.json b/package-lock.json index 394fa87..9af07f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index dd65bb1..c92a9e4 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/patches/react-native-android-widget+0.21.0.patch b/patches/react-native-android-widget+0.21.0.patch new file mode 100644 index 0000000..40bf1f4 --- /dev/null +++ b/patches/react-native-android-widget+0.21.0.patch @@ -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 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); + } + } diff --git a/src/services/widgetState.ts b/src/services/widgetState.ts new file mode 100644 index 0000000..0035ecd --- /dev/null +++ b/src/services/widgetState.ts @@ -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 { + 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 { + await AsyncStorage.setItem(WIDGET_STATE_KEY, JSON.stringify(state)); +} diff --git a/src/services/widgetSync.ts b/src/services/widgetSync.ts index 0b6555b..6c11cc2 100644 --- a/src/services/widgetSync.ts +++ b/src/services/widgetSync.ts @@ -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 { - 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 { - 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 { if (Platform.OS !== 'android') return; diff --git a/src/widgets/TaskListWidget.tsx b/src/widgets/TaskListWidget.tsx index c084192..8508756 100644 --- a/src/widgets/TaskListWidget.tsx +++ b/src/widgets/TaskListWidget.tsx @@ -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, diff --git a/src/widgets/widgetTaskHandler.ts b/src/widgets/widgetTaskHandler.ts index 86380a0..1da6e5d 100644 --- a/src/widgets/widgetTaskHandler.ts +++ b/src/widgets/widgetTaskHandler.ts @@ -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; diff --git a/tests/smoke.test.cjs b/tests/smoke.test.cjs index 12e47ca..fd5a11b 100644 --- a/tests/smoke.test.cjs +++ b/tests/smoke.test.cjs @@ -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);