From b8ad0af7256039d56492e758348047aed2a95ea8 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Tue, 23 Jun 2026 17:10:32 -0700 Subject: [PATCH 1/5] Android: run asset extraction off the platform main thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extractAsset/unzipAsset ran synchronously on the Android platform main thread, blocking Choreographer and starving Flutter's vsync — which froze on-screen animations (e.g. the boot spinner) during app unpacking on first launch. Run those two handlers on a background single-thread Executor and post the MethodChannel Result back on the main looper. loadLibrary stays on the main thread on purpose: pyjnius's JNI_OnLoad relies on running there, and loading it off-thread breaks pyjnius (verified). --- .../serious_python_android/AndroidPlugin.java | 53 ++++++++++++++----- 1 file changed, 39 insertions(+), 14 deletions(-) diff --git a/src/serious_python_android/android/src/main/java/com/flet/serious_python_android/AndroidPlugin.java b/src/serious_python_android/android/src/main/java/com/flet/serious_python_android/AndroidPlugin.java index e207744f..2a7ecff8 100644 --- a/src/serious_python_android/android/src/main/java/com/flet/serious_python_android/AndroidPlugin.java +++ b/src/serious_python_android/android/src/main/java/com/flet/serious_python_android/AndroidPlugin.java @@ -5,6 +5,11 @@ import androidx.annotation.NonNull; import android.system.Os; import android.content.Intent; +import android.os.Handler; +import android.os.Looper; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import io.flutter.embedding.engine.plugins.FlutterPlugin; import io.flutter.embedding.engine.plugins.activity.ActivityAware; @@ -31,6 +36,25 @@ public class AndroidPlugin implements FlutterPlugin, MethodCallHandler, Activity private MethodChannel channel; private Context context; + // Heavy native work (asset extraction/unzipping, native library loading) must + // NOT run on the platform main thread: it would block Android's Choreographer + // and starve Flutter's vsync, freezing on-screen animations (e.g. the boot + // spinner). Run it on a background executor and post the MethodChannel result + // back on the main thread (Flutter requires result callbacks there). + private final ExecutorService ioExecutor = Executors.newSingleThreadExecutor(); + private final Handler mainHandler = new Handler(Looper.getMainLooper()); + + private void runAsync(@NonNull Result result, String errorCode, Callable work) { + ioExecutor.execute(() -> { + try { + Object value = work.call(); + mainHandler.post(() -> result.success(value)); + } catch (Throwable e) { + mainHandler.post(() -> result.error(errorCode, e.getMessage(), null)); + } + }); + } + @Override public void onAttachedToEngine(@NonNull FlutterPluginBinding flutterPluginBinding) { channel = new MethodChannel(flutterPluginBinding.getBinaryMessenger(), @@ -109,6 +133,10 @@ public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) { // libdart_bridge — runs the library's JNI_OnLoad. That's how pyjnius's // helper (libpyjni.so) captures the JavaVM + app ClassLoader. Called from // app code here, so JNI_OnLoad sees the app's class loader. + // + // MUST stay on the platform main thread. Loading off a background thread + // (even with the worker's context ClassLoader pinned to the app loader) + // breaks pyjnius — its JNI_OnLoad relies on running on the main thread. try { System.loadLibrary((String) call.argument("libname")); result.success(null); @@ -117,9 +145,9 @@ public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) { } } else if (call.method.equals("extractAsset")) { // Stream an APK asset to disk as one whole file (e.g. stdlib.zip). - try { - String asset = call.argument("asset"); - String dest = call.argument("dest"); + final String asset = call.argument("asset"); + final String dest = call.argument("dest"); + runAsync(result, "extractAsset", () -> { java.io.File destFile = new java.io.File(dest); if (destFile.getParentFile() != null) destFile.getParentFile().mkdirs(); byte[] buf = new byte[1 << 16]; @@ -128,15 +156,13 @@ public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) { int n; while ((n = in.read(buf)) > 0) out.write(buf, 0, n); } - result.success(dest); - } catch (Exception e) { - result.error("extractAsset", e.getMessage(), null); - } + return dest; + }); } else if (call.method.equals("unzipAsset")) { // Unpack an APK asset zip (e.g. extract.zip) into a directory tree. - try { - String asset = call.argument("asset"); - String destDir = call.argument("dest"); + final String asset = call.argument("asset"); + final String destDir = call.argument("dest"); + runAsync(result, "unzipAsset", () -> { java.io.File root = new java.io.File(destDir); byte[] buf = new byte[1 << 16]; try (java.io.InputStream in = context.getAssets().open(asset); @@ -155,10 +181,8 @@ public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) { } } } - result.success(destDir); - } catch (Exception e) { - result.error("unzipAsset", e.getMessage(), null); - } + return destDir; + }); } else { result.notImplemented(); } @@ -167,6 +191,7 @@ public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) { @Override public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) { channel.setMethodCallHandler(null); + ioExecutor.shutdown(); } @Override From 409b81a60682730a4b884d95de939a49733bb446 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Wed, 24 Jun 2026 10:51:54 -0700 Subject: [PATCH 2/5] Android: keep pyjnius bootstrap classes from R8 in release builds In release builds the consuming app's R8 pass obfuscated/stripped the plugin's classes, breaking pyjnius: PythonActivity was renamed (e.g. to "C.f") and its static `mActivity` field dropped, so pyjnius failed with "type object 'C.f' has no attribute 'mActivity'". Debug builds (no minify) were unaffected. Ship consumer ProGuard rules (-keep com.flet.serious_python_android.**) and wire them via consumerProguardFiles, so they're merged into the consuming app's R8 pass automatically. --- src/serious_python_android/android/build.gradle.kts | 4 ++++ src/serious_python_android/android/consumer-rules.pro | 10 ++++++++++ 2 files changed, 14 insertions(+) create mode 100644 src/serious_python_android/android/consumer-rules.pro diff --git a/src/serious_python_android/android/build.gradle.kts b/src/serious_python_android/android/build.gradle.kts index a34b3d28..b988d94f 100644 --- a/src/serious_python_android/android/build.gradle.kts +++ b/src/serious_python_android/android/build.gradle.kts @@ -78,6 +78,10 @@ configure { ndk { abiFilters.addAll(abis) } + // Keep rules for classes pyjnius / the native runtime resolve by name at + // runtime (e.g. PythonActivity.mActivity); merged into the consuming app's + // R8 pass so release builds don't obfuscate/strip them. + consumerProguardFiles("consumer-rules.pro") } // No jniLibs packaging config needed: the native modules are real ELF .so that diff --git a/src/serious_python_android/android/consumer-rules.pro b/src/serious_python_android/android/consumer-rules.pro new file mode 100644 index 00000000..e7bb0afc --- /dev/null +++ b/src/serious_python_android/android/consumer-rules.pro @@ -0,0 +1,10 @@ +# Consumer ProGuard/R8 rules — automatically applied to apps that depend on +# serious_python_android. +# +# pyjnius and the native runtime look these classes (and their members) up by +# name via JNI/reflection at runtime. Without these keep rules, release-mode R8 +# minification renames/strips them — e.g. PythonActivity -> "C.f" and its static +# `mActivity` field is dropped — which breaks pyjnius with: +# pyjnius: not available on this platform - +# type object 'C.f' has no attribute 'mActivity' +-keep class com.flet.serious_python_android.** { *; } From 8fe9c0e3853a8614ac6dc972bcf815e50f0871d5 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Wed, 24 Jun 2026 11:28:42 -0700 Subject: [PATCH 3/5] Android: also run loadLibrary off the platform main thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that release builds keep the pyjnius bootstrap classes (consumer ProGuard rules), loadLibrary can move off the main thread too — the earlier pyjnius breakage was R8 obfuscation, not the threading. System.loadLibrary resolves the .so via the caller class's loader (AndroidPlugin -> app loader) and JNI_OnLoad's FindClass uses that same loader regardless of thread; the worker's context ClassLoader is pinned to the app loader as well. Verified with pyjnius in release. This keeps the dlopen + JNI_OnLoad off Choreographer's thread, so the boot spinner stays smooth during native library loading. --- .../serious_python_android/AndroidPlugin.java | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/src/serious_python_android/android/src/main/java/com/flet/serious_python_android/AndroidPlugin.java b/src/serious_python_android/android/src/main/java/com/flet/serious_python_android/AndroidPlugin.java index 2a7ecff8..bb9c636b 100644 --- a/src/serious_python_android/android/src/main/java/com/flet/serious_python_android/AndroidPlugin.java +++ b/src/serious_python_android/android/src/main/java/com/flet/serious_python_android/AndroidPlugin.java @@ -131,18 +131,25 @@ public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) { // Load a native library by name via Java's System.loadLibrary(), which — // unlike dart:ffi's dlopen-based DynamicLibrary.open used for // libdart_bridge — runs the library's JNI_OnLoad. That's how pyjnius's - // helper (libpyjni.so) captures the JavaVM + app ClassLoader. Called from - // app code here, so JNI_OnLoad sees the app's class loader. + // helper (libpyjni.so) captures the JavaVM + app ClassLoader. // - // MUST stay on the platform main thread. Loading off a background thread - // (even with the worker's context ClassLoader pinned to the app loader) - // breaks pyjnius — its JNI_OnLoad relies on running on the main thread. - try { - System.loadLibrary((String) call.argument("libname")); - result.success(null); - } catch (Throwable e) { - result.error("loadLibrary", e.getMessage(), null); - } + // Run off the main thread (dlopen + JNI_OnLoad can be slow). System.loadLibrary + // resolves the .so via the calling class's loader (AndroidPlugin -> app loader) + // regardless of thread, and JNI_OnLoad's FindClass uses that same loader; we + // also pin the worker's context loader to the app loader so JNI_OnLoad sees it + // if it reads the thread context loader. + final String libname = call.argument("libname"); + runAsync(result, "loadLibrary", () -> { + Thread t = Thread.currentThread(); + ClassLoader prev = t.getContextClassLoader(); + t.setContextClassLoader(context.getClassLoader()); + try { + System.loadLibrary(libname); + } finally { + t.setContextClassLoader(prev); + } + return null; + }); } else if (call.method.equals("extractAsset")) { // Stream an APK asset to disk as one whole file (e.g. stdlib.zip). final String asset = call.argument("asset"); From c331fc988d9ec5201aec5e9af8c3301d1cd571ad Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Wed, 24 Jun 2026 11:30:15 -0700 Subject: [PATCH 4/5] Bump Flutter version to 3.44.3 Update .fvmrc to reference Flutter 3.44.3 (was 3.44.2). This patches the project SDK to the latest patch release to pick up bug fixes and minor improvements. --- .fvmrc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.fvmrc b/.fvmrc index afd20432..efcdf3cd 100644 --- a/.fvmrc +++ b/.fvmrc @@ -1,3 +1,3 @@ { - "flutter": "3.44.2" + "flutter": "3.44.3" } \ No newline at end of file From 69a122f93e58ecb0959dacc1105d1297b77489ba Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Wed, 24 Jun 2026 12:31:35 -0700 Subject: [PATCH 5/5] Bump serious_python to 4.1.0 Android boot-time smoothness + release-mode pyjnius fix: - run extractAsset / unzipAsset / loadLibrary off the platform main thread so asset unpacking and native library loading no longer block Choreographer / vsync (boot animations stay smooth) - consumer ProGuard rules keeping the pyjnius bootstrap classes so release (minified) builds don't strip PythonActivity.mActivity Lockstep bump across all packages (pubspecs, darwin podspec, android gradle) plus changelogs. --- src/serious_python/CHANGELOG.md | 4 ++++ src/serious_python/pubspec.yaml | 2 +- src/serious_python_android/CHANGELOG.md | 6 ++++++ src/serious_python_android/android/build.gradle.kts | 2 +- src/serious_python_android/pubspec.yaml | 2 +- src/serious_python_darwin/CHANGELOG.md | 4 ++++ .../darwin/serious_python_darwin.podspec | 2 +- src/serious_python_darwin/pubspec.yaml | 2 +- src/serious_python_linux/CHANGELOG.md | 4 ++++ src/serious_python_linux/pubspec.yaml | 2 +- src/serious_python_platform_interface/CHANGELOG.md | 4 ++++ src/serious_python_platform_interface/pubspec.yaml | 2 +- src/serious_python_windows/CHANGELOG.md | 4 ++++ src/serious_python_windows/pubspec.yaml | 2 +- 14 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/serious_python/CHANGELOG.md b/src/serious_python/CHANGELOG.md index ab930e35..9fe964c7 100644 --- a/src/serious_python/CHANGELOG.md +++ b/src/serious_python/CHANGELOG.md @@ -1,3 +1,7 @@ +## 4.1.0 + +* **Android:** run first-launch asset unpacking and native library loading off the platform main thread so they no longer block vsync — boot-time animations (e.g. a splash / boot screen spinner) stay smooth while the app starts. Also ship consumer ProGuard rules that keep the pyjnius bootstrap classes, fixing pyjnius in release (minified) Android builds. See `serious_python_android` 4.1.0. + ## 4.0.0 * **App packaging lifted into serious_python.** Your Python app now ships **unpacked inside the application bundle**, next to the Python stdlib and site-packages, on macOS / iOS / Windows / Linux — no first-launch `app.zip` extraction. On **Android** the app ships as a *stored* `app.zip` asset inside the APK and is unpacked once (version-keyed) to the app-support files dir on the first launch after an install/update, like the existing `extract.zip`. Web (Pyodide) is unchanged. The `package` command stages the processed app into **`SERIOUS_PYTHON_APP`** (symmetric with `SERIOUS_PYTHON_SITE_PACKAGES`); each platform's native build copies it into the bundle (Android zips it as a stored asset). diff --git a/src/serious_python/pubspec.yaml b/src/serious_python/pubspec.yaml index 8839d397..3ec931c6 100644 --- a/src/serious_python/pubspec.yaml +++ b/src/serious_python/pubspec.yaml @@ -2,7 +2,7 @@ name: serious_python description: A cross-platform plugin for adding embedded Python runtime to your Flutter apps. homepage: https://flet.dev repository: https://github.com/flet-dev/serious-python -version: 4.0.0 +version: 4.1.0 platforms: ios: diff --git a/src/serious_python_android/CHANGELOG.md b/src/serious_python_android/CHANGELOG.md index f6a44a9a..54eafbf5 100644 --- a/src/serious_python_android/CHANGELOG.md +++ b/src/serious_python_android/CHANGELOG.md @@ -1,3 +1,9 @@ +## 4.1.0 + +* Run the `extractAsset` / `unzipAsset` / `loadLibrary` method-channel handlers on a background `Executor` (posting the `Result` back on the main looper) instead of inline on the platform main thread. The first-launch asset unpack and the pyjnius native-library load no longer block Android's `Choreographer`, so Flutter's vsync isn't starved and on-screen animations (e.g. a boot/splash spinner) stay smooth while the app starts. +* Ship consumer ProGuard/R8 keep rules (`-keep class com.flet.serious_python_android.** { *; }`) so release (minified) builds don't obfuscate or strip the classes pyjnius resolves by name at runtime. Without them R8 renamed `PythonActivity` and dropped its static `mActivity` field, breaking pyjnius in release builds with `type object 'C.f' has no attribute 'mActivity'` (debug builds were unaffected). +* Version bump aligning with the `serious_python_*` 4.1.0 release. + ## 4.0.0 * Ship the app as a *stored* `app.zip` asset in the APK and unpack it once (version-keyed) to `/flet/app` on the first launch after install/update, via the new `prepareApp()`. The version-keyed unpack moved out of `run()`; user data in the sibling `/data` is preserved across updates. diff --git a/src/serious_python_android/android/build.gradle.kts b/src/serious_python_android/android/build.gradle.kts index b988d94f..dc41a973 100644 --- a/src/serious_python_android/android/build.gradle.kts +++ b/src/serious_python_android/android/build.gradle.kts @@ -21,7 +21,7 @@ buildscript { } group = "com.flet.serious_python_android" -version = "4.0.0" +version = "4.1.0" rootProject.allprojects { repositories { diff --git a/src/serious_python_android/pubspec.yaml b/src/serious_python_android/pubspec.yaml index dcba567a..bef524ef 100644 --- a/src/serious_python_android/pubspec.yaml +++ b/src/serious_python_android/pubspec.yaml @@ -2,7 +2,7 @@ name: serious_python_android description: Android implementation of the serious_python plugin homepage: https://flet.dev repository: https://github.com/flet-dev/serious-python -version: 4.0.0 +version: 4.1.0 environment: sdk: ">=3.0.0 <4.0.0" diff --git a/src/serious_python_darwin/CHANGELOG.md b/src/serious_python_darwin/CHANGELOG.md index af7f33a2..7a977af4 100644 --- a/src/serious_python_darwin/CHANGELOG.md +++ b/src/serious_python_darwin/CHANGELOG.md @@ -1,3 +1,7 @@ +## 4.1.0 + +* Version bump aligning with the `serious_python_*` 4.1.0 release. + ## 4.0.0 * **Swift Package Manager support (dual with CocoaPods).** The plugin now builds under SPM as well as CocoaPods, so apps can use either integration (CocoaPods goes read-only in December 2026; Flutter ships SPM on by default since 3.44). A new `darwin/serious_python_darwin/Package.swift` builds the same Swift source as the podspec, with `getResourcePath` resolving `Bundle.module` under SPM (`#if SWIFT_PACKAGE`) and the framework `python.bundle` under CocoaPods. diff --git a/src/serious_python_darwin/darwin/serious_python_darwin.podspec b/src/serious_python_darwin/darwin/serious_python_darwin.podspec index a40434af..7c50571d 100644 --- a/src/serious_python_darwin/darwin/serious_python_darwin.podspec +++ b/src/serious_python_darwin/darwin/serious_python_darwin.podspec @@ -4,7 +4,7 @@ # Pod::Spec.new do |s| s.name = 'serious_python_darwin' - s.version = '4.0.0' + s.version = '4.1.0' s.summary = 'A cross-platform plugin for adding embedded Python runtime to your Flutter apps.' s.description = <<-DESC A cross-platform plugin for adding embedded Python runtime to your Flutter apps. diff --git a/src/serious_python_darwin/pubspec.yaml b/src/serious_python_darwin/pubspec.yaml index 8c2e09ee..b5c3c0cd 100644 --- a/src/serious_python_darwin/pubspec.yaml +++ b/src/serious_python_darwin/pubspec.yaml @@ -2,7 +2,7 @@ name: serious_python_darwin description: iOS and macOS implementations of the serious_python plugin homepage: https://flet.dev repository: https://github.com/flet-dev/serious-python -version: 4.0.0 +version: 4.1.0 environment: # The Swift Package Manager build path needs Flutter 3.44 / Dart 3.11 (the diff --git a/src/serious_python_linux/CHANGELOG.md b/src/serious_python_linux/CHANGELOG.md index 4592a1b7..0cbc4374 100644 --- a/src/serious_python_linux/CHANGELOG.md +++ b/src/serious_python_linux/CHANGELOG.md @@ -1,3 +1,7 @@ +## 4.1.0 + +* Version bump aligning with the `serious_python_*` 4.1.0 release. + ## 4.0.0 * `prepareApp()` returns `/app`; the app's Python sources ship unpacked next to the bundled stdlib + site-packages (no first-launch extraction). The CMakeLists copies `SERIOUS_PYTHON_APP` into the bundle. diff --git a/src/serious_python_linux/pubspec.yaml b/src/serious_python_linux/pubspec.yaml index 008cc136..25fec40e 100644 --- a/src/serious_python_linux/pubspec.yaml +++ b/src/serious_python_linux/pubspec.yaml @@ -2,7 +2,7 @@ name: serious_python_linux description: Linux implementations of the serious_python plugin homepage: https://flet.dev repository: https://github.com/flet-dev/serious-python -version: 4.0.0 +version: 4.1.0 environment: sdk: '>=3.1.3 <4.0.0' diff --git a/src/serious_python_platform_interface/CHANGELOG.md b/src/serious_python_platform_interface/CHANGELOG.md index 9c171132..913edd0a 100644 --- a/src/serious_python_platform_interface/CHANGELOG.md +++ b/src/serious_python_platform_interface/CHANGELOG.md @@ -1,3 +1,7 @@ +## 4.1.0 + +* Version bump aligning with the `serious_python_*` 4.1.0 release. + ## 4.0.0 * Add `prepareApp()` to `SeriousPythonPlatform` — materializes the packaged app on disk (if needed) and returns the directory containing its entry point. diff --git a/src/serious_python_platform_interface/pubspec.yaml b/src/serious_python_platform_interface/pubspec.yaml index 6d8c433d..be4f127c 100644 --- a/src/serious_python_platform_interface/pubspec.yaml +++ b/src/serious_python_platform_interface/pubspec.yaml @@ -2,7 +2,7 @@ name: serious_python_platform_interface description: A common platform interface for the serious_python plugin. homepage: https://flet.dev repository: https://github.com/flet-dev/serious-python -version: 4.0.0 +version: 4.1.0 environment: sdk: ">=3.0.0 <4.0.0" diff --git a/src/serious_python_windows/CHANGELOG.md b/src/serious_python_windows/CHANGELOG.md index 13c9482f..9e51d374 100644 --- a/src/serious_python_windows/CHANGELOG.md +++ b/src/serious_python_windows/CHANGELOG.md @@ -1,3 +1,7 @@ +## 4.1.0 + +* Version bump aligning with the `serious_python_*` 4.1.0 release. + ## 4.0.0 * `prepareApp()` returns `/app`; the app's Python sources ship unpacked next to the bundled CPython `Lib`/`DLLs`/`site-packages` (no first-launch extraction). The CMakeLists copies `SERIOUS_PYTHON_APP` into the runner output. diff --git a/src/serious_python_windows/pubspec.yaml b/src/serious_python_windows/pubspec.yaml index 820c6b3e..f80be415 100644 --- a/src/serious_python_windows/pubspec.yaml +++ b/src/serious_python_windows/pubspec.yaml @@ -2,7 +2,7 @@ name: serious_python_windows description: Windows implementations of the serious_python plugin homepage: https://flet.dev repository: https://github.com/flet-dev/serious-python -version: 4.0.0 +version: 4.1.0 environment: sdk: '>=3.1.3 <4.0.0'