From d714bd709044c1eef6a545cbc670e61efa67b435 Mon Sep 17 00:00:00 2001 From: Logan Date: Thu, 20 Aug 2026 13:04:04 -0400 Subject: [PATCH] fix(android): keep the Go app alive when the activity is destroyed onDestroy called bridge.shutdown(), which is the natural reading of the callback and is wrong for this app twice over. Android destroys and recreates an activity without restarting the process, and when the user really does leave, this app's reason for existing in the background is that a song is playing -- which is what the mediaPlayback foreground service holds the process alive for. Either way, tearing the Go side down here stops the music. It was harmless only by accident, and that is worth writing down: nativeShutdown calls App.Quit(), whose Android destroy() is an empty method, and Run()'s deferred shutdownServices() cannot fire because platformRun is `select{}` and never returns. So **no ServiceShutdown has ever run on Android**. Removing the call changes nothing today; it stops the day someone implements destroy() from silently killing playback on a rotation. There is no callback for the process going away -- Android just kills it -- so durability here is the persist writers, which submit on every mutation rather than at exit. WailsBridge.initialize gains the comment for the trap next to it. Making `initialized` static is the obvious reading of "initialise once per process" and is wrong: nativeInit also stores the global JNI reference to *this* bridge, so skipping it leaves Go executing JavaScript against the destroyed activity's WebView, and the app opens, renders, and never receives another backend event. The half that must not repeat is latched in Go instead -- which is also where the damage was, and the only place that can see it. Refs #52 --- .../main/java/com/wails/app/MainActivity.java | 34 +++++++++++++++++-- .../main/java/com/wails/app/WailsBridge.java | 19 ++++++++++- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/build/android/app/src/main/java/com/wails/app/MainActivity.java b/build/android/app/src/main/java/com/wails/app/MainActivity.java index 0840ded..9c84164 100644 --- a/build/android/app/src/main/java/com/wails/app/MainActivity.java +++ b/build/android/app/src/main/java/com/wails/app/MainActivity.java @@ -891,13 +891,41 @@ public class MainActivity extends AppCompatActivity { } } + /** + * The activity going away is not the app shutting down. + * + *

The scaffold called {@code bridge.shutdown()} here, which is + * the natural reading of onDestroy and is wrong for this app twice + * over. Android destroys and recreates an activity for a + * configuration change the manifest does not declare, under memory + * pressure, and on every background if the user has "Don't keep + * activities" on -- all **without restarting the process**. And + * when the user really does leave, this app's reason for existing + * in the background is that a song is playing, which is what the + * {@code mediaPlayback} foreground service is holding the process + * alive for. Either way, tearing the Go side down here would stop + * the music. + * + *

It was harmless only by accident: {@code nativeShutdown} calls + * {@code App.Quit()}, whose Android {@code destroy()} is an empty + * method, and {@code Run()}'s deferred {@code shutdownServices()} + * can never fire because Android's {@code platformRun} is + * {@code select{}} and does not return. So no {@code + * ServiceShutdown} has ever run on Android, and removing this call + * changes nothing today -- it stops the day someone implements + * {@code destroy()} from silently killing playback on a rotation. + * + *

There is no callback for "the process is going away"; Android + * simply kills it. Durability on this platform is the persist + * writers, which submit on every mutation rather than at exit. + * + *

See #52, and CLAUDE.md, "An activity is a view onto the + * process". + */ @Override protected void onDestroy() { super.onDestroy(); unregisterSystemEventReceivers(); - if (bridge != null) { - bridge.shutdown(); - } if (webView != null) { webView.destroy(); } diff --git a/build/android/app/src/main/java/com/wails/app/WailsBridge.java b/build/android/app/src/main/java/com/wails/app/WailsBridge.java index 6198770..ce912cc 100644 --- a/build/android/app/src/main/java/com/wails/app/WailsBridge.java +++ b/build/android/app/src/main/java/com/wails/app/WailsBridge.java @@ -129,7 +129,24 @@ public class WailsBridge { } /** - * Initialize the native Go library + * Initialize the native Go library. + * + *

{@code initialized} is deliberately per-instance, and making + * it {@code static} is the trap this comment exists for. A + * recreated activity builds a new bridge and calls this again, in a + * process where the native library is already loaded and Go's + * {@code main()} is already running -- so "initialise once per + * process" looks like exactly the right rule. It is not, because + * {@code nativeInit} does two things: it runs + * {@code go mainFunc()}, and it stores the global JNI reference to + * this bridge. Skip it and Go keeps executing JavaScript + * against the destroyed activity's WebView: the app opens, renders, + * and never receives another backend event. + * + *

So this is called every time, and the half that must not repeat + * is latched on the Go side instead, at the top of {@code main()} -- + * which is also where the damage was ({@code os.Exit(1)}), and the + * only place that can see it. See #52. */ public void initialize() { if (initialized) {