Skip to content

[shared_preferences_android] Keep API registration when another engine detaches - #13359

Closed
zkrige wants to merge 3 commits into
flutter:mainfrom
zkrige:shared-preferences-android-keep-registration
Closed

zkrige wants to merge 3 commits into
flutter:mainfrom
zkrige:shared-preferences-android-keep-registration

Conversation

@zkrige

@zkrige zkrige commented Oct 21, 2026 •

Copy link
Copy Markdown

Detaching one Flutter engine no longer removes the legacy SharedPreferencesApi registration that another attached engine still uses.

SharedPreferencesApiInstances (Messages.g.kt:47) is a process-wide map. LegacySharedPreferencesPlugin.onDetachedFromEngine called SharedPreferencesApiRegistrar().register(null), which removes the default entry (Messages.g.kt:87). When a background engine (WorkManager, FCM background handler) detached, the main engine's next SharedPreferences.getInstance() threw No instance with no name has been registered (messages.g.dart:179).

The plugin now keeps a list of attached instances. On detach it removes itself and re-registers the most recently attached one that is still attached, or unregisters if none remain.

SharedPreferencesPlugin.onDetachedFromEngine unregisters the data_store entry the same way (SharedPreferencesPlugin.kt:73). I left that out of this PR to keep it small and described it in the issue.

Fixes flutter/flutter#194920

Test: registrationSurvivesOtherEngineDetaching in LegacySharedPreferencesTest.kt attaches two plugins, detaches the later one, and checks the registered instance still serves the first plugin's data. It fails without the change (NoSuchElementException, nothing registered) and passes with it.

Pre-Review Checklist

Footnotes

  1. Version bumped to 2.5.3 (patch) with a CHANGELOG entry. ↩ ↩2

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request updates the Android implementation of the shared_preferences plugin to track active plugin instances in a synchronized list, ensuring that detaching one Flutter engine does not unregister the plugin for other active engines. Feedback suggests wrapping the plugin registration and de-registration compound operations in synchronized blocks to ensure atomicity and prevent race conditions. Additionally, it is recommended to expose the attachedPlugins list as @VisibleForTesting so that unit tests can clear it to prevent memory leaks and ensure test isolation.

Comment on lines +41 to 42
attachedPlugins.add(this)
SharedPreferencesApiRegistrar().register(this)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

To ensure thread safety and prevent race conditions when multiple engines are initialized or destroyed concurrently, the modification of attachedPlugins and the registration of the plugin should be performed atomically. Wrapping these operations in a synchronized block on attachedPlugins prevents other threads from registering an inconsistent state.

      synchronized(attachedPlugins) {
        attachedPlugins.add(this)
        SharedPreferencesApiRegistrar().register(this)
      }

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Attach and detach now each run under synchronized(attachedPlugins). Detach callbacks fire from FlutterEngineConnectionRegistry.remove (line 275) before FlutterJNI's main thread check runs in FlutterEngine.destroy, so I can't rely on a main thread guarantee there.

Comment on lines +53 to +54
attachedPlugins.remove(this)
SharedPreferencesApiRegistrar().register(attachedPlugins.lastOrNull())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Since attachedPlugins is a synchronized list, individual operations like remove are thread-safe, but compound operations (such as remove followed by lastOrNull()) are not atomic. lastOrNull() is an extension function that checks if the list is empty and then retrieves the last element; if another thread modifies the list concurrently between these steps, it can throw an IndexOutOfBoundsException or register the wrong plugin instance. Wrapping this in a synchronized block on attachedPlugins ensures atomicity.

    synchronized(attachedPlugins) {
      attachedPlugins.remove(this)
      SharedPreferencesApiRegistrar().register(attachedPlugins.lastOrNull())
    }

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Attach and detach now each run under synchronized(attachedPlugins). Detach callbacks fire from FlutterEngineConnectionRegistry.remove (line 275) before FlutterJNI's main thread check runs in FlutterEngine.destroy, so I can't rely on a main thread guarantee there.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In what context could those method run on anything but the main thread? That would violate the engine's threading contract.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, attach and detach run on the main thread. Removed the lock.

Comment on lines +188 to +189
private val attachedPlugins =
Collections.synchronizedList(mutableListOf<LegacySharedPreferencesPlugin>())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The attachedPlugins list is a static companion object property, meaning it persists across the entire lifetime of the JVM process. In unit tests, plugins are attached but never detached (except for backgroundPlugin in registrationSurvivesOtherEngineDetaching), which causes plugin instances (and their mocked Context and Binding references) to leak and accumulate across tests. Consider exposing attachedPlugins as internal and @VisibleForTesting so that tests can clear it in an @After teardown block to maintain test isolation and prevent memory leaks.

Suggested change
private val attachedPlugins =
Collections.synchronizedList(mutableListOf<LegacySharedPreferencesPlugin>())
@VisibleForTesting
internal val attachedPlugins =
Collections.synchronizedList(mutableListOf<LegacySharedPreferencesPlugin>())

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. attachedPlugins is now @VisibleForTesting internal and LegacySharedPreferencesTest clears it in an @after.

@loic-sharma
loic-sharma requested a review from jesswrd October 27, 2026 20:39
@loic-sharma loic-sharma added the triage-android Should be looked at in Android triage label Oct 27, 2026
Plugin attach and detach run on the platform main thread: FlutterJNI.attachToNative and detachFromNativeAndReleaseResources both enforce it, so the lock guarded a case the engine contract rules out.
@stuartmorgan

Copy link
Copy Markdown
Contributor

The plugin now keeps a list of attached instances. On detach it removes itself and re-registers the most recently attached one that is still attached

Doesn't this mean that if N instances are created, one of them is arbitrarily attached and the others are doing nothing?

It seems like this is hacking around this issue rather than resolving the real problem. It might happen to currently work for this plugin since it is wrapping shared state, but the code still won't be doing what is intended.

@zkrige

zkrige commented Oct 29, 2026

Copy link
Copy Markdown
Author

Agreed, this works around the registrar rather than fixing it.
Proposal: key the Pigeon JNI registrar by engine id, matched in Dart with PlatformDispatcher.instance.engineId.
Plugins only reach that id through the deprecated FlutterPluginBinding.getFlutterEngine(), so the binding would need an engine id accessor.
I can make the Pigeon change and rebase this onto it. Does that direction work for you and @tarrinneal?

@tarrinneal

tarrinneal commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Keying Pigeon's JNI/FFI registrar by engineId makes sense long-term for plugins with per-engine state, rapid adoption in Pigeon isn't possible right now because the engine doesn't publicly expose engineId on FlutterPluginBinding (Android's binding.getFlutterEngine() is @Deprecated, and iOS/macOS's engineIdentifier is in an internal header). Even if we add getEngineId() to FlutterPluginBinding in flutter/flutter now, it will take a full stable release cycle before plugins here can rely on it.

In the meantime, Pigeon's JNI/FFI registrar is a process-wide registry (not per-engine like BinaryMessenger). Since shared_preferences_android only uses binding.applicationContext—which is already a process-wide Application context shared across all engines—it doesn't have per-engine state to tear down when an individual engine detaches (similar to how shared_preferences_foundation on iOS/macOS registers once and doesn't unregister on detach).

Note that this PR currently only updates LegacySharedPreferencesPlugin, while SharedPreferencesPlugin ("data_store" and "shared_preferences") still calls SharedPreferencesAsyncApiRegistrar(null).register in onDetachedFromEngine and has the same issue.

http://github.com/flutter/flutter/issues/195876

auto-submit Bot pushed a commit to flutter/flutter that referenced this pull request Nov 9, 2026
…droid, iOS, and macOS (#196145)

### Issue Link:
#195876

### Impact Description:
Native plugins using JNI/FFI (such as plugins generated with Pigeon's native interop support) register directly in native memory rather than on a per-engine `BinaryMessenger`, and have no non-deprecated public API on `FlutterPluginBinding` (Android) or `FlutterPluginRegistrar` (iOS/macOS) to obtain the engine identifier during plugin attachment and detachment. In apps running multiple `FlutterEngine` instances in the same process (such as background engines alongside the main UI engine), one engine attaching or detaching can overwrite or unregister another engine's JNI/FFI host API instance (#194920). This blocks shipping multi-engine-safe FFI/JNI plugins (flutter/packages#13359) against the 3.50 stable release.

**Scope of the API break:** This supersedes #195991, which was closed as a breaking change. As [@stuartmorgan noted](#195991 (comment)), the `@required` `engineIdentifier` property on `FlutterBaseRegistrar` (iOS) and `FlutterPluginRegistrar` (macOS) only affects hand-written native unit-test fakes that conform to those protocols directly. Production plugin and app code only consumes registrars provided by the engine and is unaffected. Following that, [@zanderso commented](#195991 (comment)) that if the change is safer than the earlier signals indicated, it should be considered for a hotfix release in this cycle. Affected test fakes can add `var engineIdentifier: Int64 = 0` (Swift) or `@synthesize engineIdentifier;` (Objective-C), which compiles both before and after this change. First-party fakes were already updated in flutter/packages#13574. On Android, `FlutterPluginBinding` is a concrete class, so adding `getEngineId()` does not affect mocks.

### Changelog Description:
[flutter/195876] When running multiple `FlutterEngine`s on Android, iOS, or macOS, FFI/JNI plugins could not determine which engine a `FlutterPluginBinding` or `FlutterPluginRegistrar` belonged to.

### Workaround:
On Android, plugins can call the deprecated `FlutterPluginBinding.getFlutterEngine().getEngineId()`. On iOS and macOS, there is no public API workaround (`engineIdentifier` is only declared in internal headers, and the registrar's weak engine reference is already `nil` during `detachFromEngineForRegistrar:`).

### Risk:
What is the risk level of this cherry-pick?

### Test Coverage:
Are you confident that your fix is well-tested by automated tests?

### Validation Steps:
1. On Android, verify `FlutterPluginBinding.getEngineId()` returns the associated `FlutterEngine.getEngineId()`.
2. On iOS, verify `registrar.engineIdentifier` on `FlutterPluginRegistrar` and `FlutterApplicationRegistrar` matches the owning `FlutterEngine.engineIdentifier`, both during registration and during `detachFromEngineForRegistrar:`.
3. On macOS, verify `registrar.engineIdentifier` on `FlutterPluginRegistrar` matches the owning `FlutterEngine.engineIdentifier`.
@tarrinneal

Copy link
Copy Markdown
Contributor

I'll have a pr up that fixes this shortly by pulling in the pigeon fix. Thank you for reporting this bug and uploading a pr to fix it!

@tarrinneal tarrinneal closed this Nov 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[shared_preferences_android] Detaching a background engine unregisters the API used by the main engine

4 participants