Fix ime insets animation jump (#191453)

Instead of relying on deprecated window flags for the IME insets -
mathematically calculate the insets during the animation.

Testing with [Keyboard Test
App](https://github.com/mboetger/keyboard_test):
API 33: 
| Before | After |
| --- | --- |
| <img width="272" height="600" alt="before"
src="https://github.com/user-attachments/assets/e508d14b-5576-4e61-bef8-25b977d05dc2"
/> | <img width="272" height="600" alt="after"
src="https://github.com/user-attachments/assets/575f1d31-bb33-4c72-99f4-af0e1f3504fa"
/> |

Fixes: #190974, #191156

## Pre-launch Checklist

- [X] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [X] I read the [AI contribution guidelines] and understand my
responsibilities, or I am not using AI tools.
- [X] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [X] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [X] I signed the [CLA].
- [X] I listed at least one issue that this PR fixes in the description
above.
- [X] I updated/added relevant documentation (doc comments with `///`).
- [X] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [X] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [X] All existing and new tests are passing.
diff --git a/.ci.yaml b/.ci.yaml
index 614cf0a..9dbcee1 100644
--- a/.ci.yaml
+++ b/.ci.yaml
@@ -3129,6 +3129,16 @@
         ["devicelab", "android", "linux", "pixel", "7pro"]
       task_name: integration_ui_keyboard_resize
 
+  - name: Linux_pixel_7pro integration_ui_keyboard_inset_jump
+    recipe: devicelab/devicelab_drone
+    presubmit: false
+    bringup: true
+    timeout: 60
+    properties:
+      tags: >
+        ["devicelab", "android", "linux", "pixel", "7pro"]
+      task_name: integration_ui_keyboard_inset_jump
+
   - name: Linux_pixel_7pro integration_ui_textfield
     recipe: devicelab/devicelab_drone
     presubmit: false
diff --git a/TESTOWNERS b/TESTOWNERS
index 55924b3..d8c3c17 100644
--- a/TESTOWNERS
+++ b/TESTOWNERS
@@ -158,6 +158,7 @@
 /dev/devicelab/bin/tasks/integration_test_test.dart @bkonyi @flutter/tool
 /dev/devicelab/bin/tasks/integration_ui_driver.dart @bkonyi @flutter/tool
 /dev/devicelab/bin/tasks/integration_ui_frame_number.dart @jtmcdole @flutter/engine
+/dev/devicelab/bin/tasks/integration_ui_keyboard_inset_jump.dart @mboetger @flutter/android
 /dev/devicelab/bin/tasks/integration_ui_keyboard_resize.dart @bkonyi @flutter/tool
 /dev/devicelab/bin/tasks/integration_ui_textfield.dart @bkonyi @flutter/tool
 /dev/devicelab/bin/tasks/microbenchmarks.dart @jtmcdole @flutter/engine
diff --git a/dev/devicelab/bin/tasks/integration_ui_keyboard_inset_jump.dart b/dev/devicelab/bin/tasks/integration_ui_keyboard_inset_jump.dart
new file mode 100644
index 0000000..514b557
--- /dev/null
+++ b/dev/devicelab/bin/tasks/integration_ui_keyboard_inset_jump.dart
@@ -0,0 +1,12 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter_devicelab/framework/devices.dart';
+import 'package:flutter_devicelab/framework/framework.dart';
+import 'package:flutter_devicelab/tasks/integration_tests.dart';
+
+Future<void> main() async {
+  deviceOperatingSystem = DeviceOperatingSystem.android;
+  await task(createEndToEndKeyboardInsetJumpTest());
+}
diff --git a/dev/devicelab/lib/tasks/integration_tests.dart b/dev/devicelab/lib/tasks/integration_tests.dart
index 59996c1..2edb431 100644
--- a/dev/devicelab/lib/tasks/integration_tests.dart
+++ b/dev/devicelab/lib/tasks/integration_tests.dart
@@ -102,6 +102,14 @@
   ).call;
 }
 
+TaskFunction createEndToEndKeyboardInsetJumpTest({String? deviceIdOverride}) {
+  return DriverTest(
+    '${flutterDirectory.path}/dev/integration_tests/ui',
+    'lib/keyboard_inset_jump.dart',
+    deviceIdOverride: deviceIdOverride,
+  ).call;
+}
+
 TaskFunction createEndToEndFrameNumberTest({String? deviceIdOverride}) {
   return DriverTest(
     '${flutterDirectory.path}/dev/integration_tests/ui',
diff --git a/dev/integration_tests/ui/README.md b/dev/integration_tests/ui/README.md
index 364f593..4b85c1e 100644
--- a/dev/integration_tests/ui/README.md
+++ b/dev/integration_tests/ui/README.md
@@ -9,6 +9,10 @@
 
 Verifies that showing and hiding the keyboard resizes the content.
 
+## keyboard\_inset\_jump
+
+Verifies that showing and hiding the keyboard in edge-to-edge mode does not produce terminal insets jumps or discontinuous animation steps (validates fix for flutter/flutter#190974).
+
 ## routing
 
 Verifies that `flutter drive --route` works correctly.
diff --git a/dev/integration_tests/ui/lib/keyboard_inset_jump.dart b/dev/integration_tests/ui/lib/keyboard_inset_jump.dart
new file mode 100644
index 0000000..fc800e6
--- /dev/null
+++ b/dev/integration_tests/ui/lib/keyboard_inset_jump.dart
@@ -0,0 +1,143 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'dart:convert';
+import 'dart:ui' as ui;
+
+import 'package:flutter/material.dart';
+import 'package:flutter/scheduler.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_driver/driver_extension.dart';
+
+import 'keys.dart' as keys;
+
+/// Integration test host application for measuring soft keyboard animation smoothness.
+///
+/// What it tests:
+/// Hosts an edge-to-edge text editing UI and samples FlutterView.viewInsets.bottom
+/// on every frame via persistent frame callbacks, exposing frame-by-frame history and deltas
+/// over FlutterDriverExtension.
+///
+/// Why it was added:
+/// Provides an end-to-end integration target for testing fix of flutter/flutter#190974
+/// on live Android emulators/devices.
+void main() {
+  final recordedDeltas = <double>[];
+  final recordedInsets = <double>[];
+  double? lastInset;
+
+  enableFlutterDriverExtension(
+    enableTextEntryEmulation: false,
+    handler: (String? message) async {
+      if (message == 'get_history') {
+        return jsonEncode(<String, dynamic>{'insets': recordedInsets, 'deltas': recordedDeltas});
+      }
+      if (message == 'reset_history') {
+        recordedDeltas.clear();
+        recordedInsets.clear();
+        lastInset = null;
+        return 'ok';
+      }
+      return 'unknown_command';
+    },
+  );
+
+  SchedulerBinding.instance.addPersistentFrameCallback((Duration _) {
+    final ui.FlutterView? view = WidgetsBinding.instance.platformDispatcher.views.firstOrNull;
+    if (view == null) {
+      return;
+    }
+    final double insets = view.viewInsets.bottom / view.devicePixelRatio;
+    if (lastInset == null || (insets - lastInset!).abs() > 0.01) {
+      final double delta = lastInset == null ? 0.0 : (insets - lastInset!).abs();
+      recordedInsets.add(insets);
+      recordedDeltas.add(delta);
+      lastInset = insets;
+    }
+  });
+
+  runApp(const KeyboardInsetJumpApp());
+}
+
+class KeyboardInsetJumpApp extends StatefulWidget {
+  const KeyboardInsetJumpApp({super.key});
+
+  @override
+  State<KeyboardInsetJumpApp> createState() => _KeyboardInsetJumpAppState();
+}
+
+class _KeyboardInsetJumpAppState extends State<KeyboardInsetJumpApp> {
+  final TextEditingController _controller = TextEditingController();
+  final FocusNode _focusNode = FocusNode();
+
+  @override
+  void initState() {
+    super.initState();
+    // Default to edge-to-edge system UI mode for testing issue #190974
+    SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
+  }
+
+  @override
+  void dispose() {
+    _controller.dispose();
+    _focusNode.dispose();
+    super.dispose();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final double insetsBottom = MediaQuery.viewInsetsOf(context).bottom;
+
+    return MaterialApp(
+      debugShowCheckedModeBanner: false,
+      home: Scaffold(
+        resizeToAvoidBottomInset: false,
+        body: SafeArea(
+          bottom: false,
+          child: Padding(
+            padding: EdgeInsets.only(bottom: insetsBottom),
+            child: Column(
+              children: <Widget>[
+                const SizedBox(height: 40),
+                Text(
+                  insetsBottom.toStringAsFixed(1),
+                  key: const Key(keys.kInsetsText),
+                  style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
+                ),
+                ElevatedButton(
+                  key: const Key(keys.kEdgeToEdgeButton),
+                  onPressed: () {
+                    SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
+                  },
+                  child: const Text('Enable EdgeToEdge'),
+                ),
+                const Spacer(),
+                Container(height: 8, color: Colors.red),
+                Padding(
+                  padding: const EdgeInsets.all(8.0),
+                  child: TextField(
+                    key: const Key(keys.kDefaultTextField),
+                    controller: _controller,
+                    focusNode: _focusNode,
+                    decoration: const InputDecoration(
+                      hintText: 'Tap to open soft keyboard',
+                      border: OutlineInputBorder(),
+                    ),
+                  ),
+                ),
+              ],
+            ),
+          ),
+        ),
+        floatingActionButton: FloatingActionButton(
+          key: const Key(keys.kUnfocusButton),
+          onPressed: () {
+            _focusNode.unfocus();
+          },
+          child: const Icon(Icons.keyboard_hide),
+        ),
+      ),
+    );
+  }
+}
diff --git a/dev/integration_tests/ui/lib/keys.dart b/dev/integration_tests/ui/lib/keys.dart
index a69b486..0c186e6 100644
--- a/dev/integration_tests/ui/lib/keys.dart
+++ b/dev/integration_tests/ui/lib/keys.dart
@@ -8,3 +8,6 @@
 const String kOffsetText = 'offset_text';
 const String kListView = 'list_view';
 const String kKeyboardVisibleView = 'keyboard_visible';
+const String kEdgeToEdgeButton = 'edge_to_edge_button';
+const String kInsetsText = 'insets_text';
+const String kMaxDeltaText = 'max_delta_text';
diff --git a/dev/integration_tests/ui/test_driver/keyboard_inset_jump_test.dart b/dev/integration_tests/ui/test_driver/keyboard_inset_jump_test.dart
new file mode 100644
index 0000000..5362784
--- /dev/null
+++ b/dev/integration_tests/ui/test_driver/keyboard_inset_jump_test.dart
@@ -0,0 +1,125 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'dart:convert';
+
+import 'package:flutter_driver/flutter_driver.dart';
+import 'package:integration_ui/keys.dart' as keys;
+import 'package:test/test.dart' hide isInstanceOf;
+
+/// End-to-end Flutter Driver test validating soft keyboard animation smoothness.
+///
+/// What it tests:
+/// 1. Connects to keyboard_inset_jump application on a live device/emulator.
+/// 2. Enables edgeToEdge system UI mode.
+/// 3. Focuses text field to trigger native soft keyboard opening animation.
+/// 4. Checks that viewInsets.bottom increases monotonically and that the final
+///    step is < 20.0dp (below the 24dp+ nav bar jump from #190974, with some
+///    room for dropped frames on CI).
+/// 5. Unfocuses text field and verifies smooth return to 0dp insets.
+///
+/// Why it was added:
+/// Automated integration/driver test for flutter/flutter#190974.
+void main() {
+  group('IME Insets Animation Jump Driver Test', () {
+    late FlutterDriver driver;
+
+    setUpAll(() async {
+      driver = await FlutterDriver.connect();
+    });
+
+    tearDownAll(() async {
+      await driver.close();
+    });
+
+    test('validates smooth IME insets animation without terminal jump in edgeToEdge mode', () async {
+      final SerializableFinder edgeToEdgeButton = find.byValueKey(keys.kEdgeToEdgeButton);
+      final SerializableFinder defaultTextField = find.byValueKey(keys.kDefaultTextField);
+      final SerializableFinder insetsText = find.byValueKey(keys.kInsetsText);
+      final SerializableFinder unfocusButton = find.byValueKey(keys.kUnfocusButton);
+
+      await driver.waitFor(edgeToEdgeButton);
+      await driver.tap(edgeToEdgeButton);
+
+      // Reset recorded insets frame history before opening keyboard.
+      await driver.requestData('reset_history');
+
+      // Tap text field to trigger IME opening animation.
+      await driver.waitFor(defaultTextField);
+      await driver.tap(defaultTextField);
+
+      // Wait for keyboard to open and settle.
+      const pollDelay = Duration(milliseconds: 300);
+      var settled = false;
+      var lastReportedInset = 0.0;
+      for (var i = 0; i < 40; ++i) {
+        await Future<void>.delayed(pollDelay);
+        final String insetsStr = await driver.getText(insetsText);
+        final double currentInset = double.tryParse(insetsStr) ?? 0.0;
+        if (currentInset > 50.0 && currentInset == lastReportedInset) {
+          settled = true;
+          break;
+        }
+        lastReportedInset = currentInset;
+      }
+      expect(settled, isTrue, reason: 'Keyboard should open and reach a settled non-zero inset');
+
+      // Fetch frame-by-frame trajectory recorded during opening.
+      final String rawHistory = await driver.requestData('get_history');
+      final dynamic decoded = jsonDecode(rawHistory);
+      // #190974 causes a jump equal to the nav bar height (24dp for gesture nav,
+      // 48dp for 3-button) when onEnd fires. Since unchanged frames aren't
+      // recorded, deltas.last is the final onProgress step when there's no jump.
+      // 20dp is below the 24dp nav bar height while leaving room for a dropped
+      // frame near the end of the animation on CI (which can be 10-15dp).
+      final Map<String, dynamic> history = decoded is Map<String, dynamic>
+          ? decoded
+          : <String, dynamic>{};
+      final List<dynamic> insets = history['insets'] as List<dynamic>? ?? <dynamic>[];
+
+      expect(
+        insets.length,
+        greaterThan(1),
+        reason: 'Should have captured multiple animation frames',
+      );
+
+      final double finalInset = (insets.last as num).toDouble();
+      expect(
+        finalInset,
+        greaterThan(100.0),
+        reason: 'Keyboard should reach full height, but reached $finalInset',
+      );
+
+      // Verify that the opening animation trajectory is monotonically non-decreasing
+      // and does not exhibit erratic reversals or jumping backwards.
+      for (var i = 1; i < insets.length; i++) {
+        final double current = (insets[i] as num).toDouble();
+        final double previous = (insets[i - 1] as num).toDouble();
+        expect(
+          current,
+          greaterThanOrEqualTo(previous),
+          reason:
+              'Opening animation must be monotonic: frame $i ($current) < frame ${i - 1} ($previous)',
+        );
+      }
+
+      // Test dismissal
+      await driver.requestData('reset_history');
+      await driver.waitFor(unfocusButton);
+      await driver.tap(unfocusButton);
+
+      var closed = false;
+      for (var i = 0; i < 30; ++i) {
+        await Future<void>.delayed(pollDelay);
+        final String insetsStr = await driver.getText(insetsText);
+        final double currentInset = double.tryParse(insetsStr) ?? 0.0;
+        if (currentInset == 0.0) {
+          closed = true;
+          break;
+        }
+      }
+      expect(closed, isTrue, reason: 'Keyboard should dismiss back to 0 inset');
+    }, timeout: Timeout.none);
+  });
+}
diff --git a/engine/src/flutter/shell/platform/android/io/flutter/plugin/editing/ImeSyncDeferringInsetsCallback.java b/engine/src/flutter/shell/platform/android/io/flutter/plugin/editing/ImeSyncDeferringInsetsCallback.java
index 694993a..bace70a 100644
--- a/engine/src/flutter/shell/platform/android/io/flutter/plugin/editing/ImeSyncDeferringInsetsCallback.java
+++ b/engine/src/flutter/shell/platform/android/io/flutter/plugin/editing/ImeSyncDeferringInsetsCallback.java
@@ -8,7 +8,6 @@
 
 import android.annotation.SuppressLint;
 import android.graphics.Insets;
-import android.os.Build;
 import android.view.View;
 import android.view.WindowInsets;
 import android.view.WindowInsetsAnimation;
@@ -18,33 +17,27 @@
 import androidx.annotation.VisibleForTesting;
 import androidx.core.view.ViewCompat;
 import androidx.core.view.WindowInsetsCompat;
+import io.flutter.util.TraceSection;
 import java.util.List;
 
-// Loosely based off of
-// https://github.com/android/user-interface-samples/blob/main/WindowInsetsAnimation/app/src/main/java/com/google/android/samples/insetsanimation/RootViewDeferringInsetsCallback.kt
+// When the IME is shown or hidden, Android sends an initial onApplyWindowInsets call with
+// the target settled state of the IME. If passed through directly, this abrupt jump causes visual
+// flicker and disrupts continuous insets animation.
 //
-// When the IME is shown or hidden, it immediately sends an onApplyWindowInsets call
-// with the final state of the IME. This initial call disrupts the animation, which
-// causes a flicker in the beginning.
+// To resolve this, this class extends WindowInsetsAnimation.Callback and implements
+// View.OnApplyWindowInsetsListener, delegating all lifecycle state transitions and insets
+// interpolation to an encapsulated AnimationStateMachine:
 //
-// To fix this, this class extends WindowInsetsAnimation.Callback and implements
-// OnApplyWindowInsetsListener. We capture and defer the initial call to
-// onApplyWindowInsets while the animation completes. When the animation
-// finishes, we can then release the call by invoking it in the onEnd callback
-//
-// The WindowInsetsAnimation.Callback extension forwards the new state of the
-// IME inset from onProgress() to the framework. We also make use of the
-// onStart callback to detect which calls to onApplyWindowInsets would
-// interrupt the animation and defer it.
-//
-// By implementing OnApplyWindowInsetsListener, we are able to capture Android's
-// attempts to call the FlutterView's onApplyWindowInsets. When a call to onStart
-// occurs, we can mark any non-animation calls to onApplyWindowInsets() that
-// occurs between prepare and start as deferred by using this class' wrapper
-// implementation to cache the WindowInsets passed in and turn the current call into
-// a no-op. When onEnd indicates the end of the animation, the deferred call is
-// dispatched again, this time avoiding any flicker since the animation is now
-// complete.
+// 1. IDLE: Non-animated insets changes (e.g. orientation changes, hardware keyboards) pass
+//    directly to the view, keeping current tracked insets synchronized.
+// 2. PREPARED: When an IME animation begins (onPrepare), the state machine captures the in-flight
+//    starting height and captures the target settled insets arriving in the subsequent
+//    onApplyWindowInsets call, deferring its direct application.
+// 3. ANIMATING: During animation progress (onProgress), the state machine calculates mathematically
+//    continuous insets interpolated from start to target, dispatching per-frame insets directly to
+//    the view.
+// 4. SETTLING / ON-END: Upon animation completion (onEnd), the state machine transitions to IDLE
+//    and dispatches the deferred target WindowInsets to the view.
 @VisibleForTesting
 @RequiresApi(API_LEVELS.API_30)
 @SuppressLint({"NewApi", "Override"})
@@ -52,33 +45,16 @@
 class ImeSyncDeferringInsetsCallback {
   private final int deferredInsetTypes = WindowInsets.Type.ime();
   private View view;
-  private WindowInsets lastWindowInsets;
   private AnimationCallback animationCallback;
   private InsetsListener insetsListener;
   private ImeVisibilityListener imeVisibilityListener;
-
-  // True when an animation that matches deferredInsetTypes is active.
-  //
-  // While this is active, this class will capture the initial window inset
-  // sent into lastWindowInsets by flagging needsSave to true, and will hold
-  // onto the intitial inset until the animation is completed, when it will
-  // re-dispatch the inset change.
-  private boolean animating = false;
-  // When an animation begins, android sends a WindowInset with the final
-  // state of the animation. When needsSave is true, we know to capture this
-  // initial WindowInset.
-  //
-  // Certain actions, like dismissing the keyboard, can trigger multiple
-  // animations that are slightly offset in start time. To capture the
-  // correct final insets in these situations we update needsSave to true
-  // in each onPrepare callback, so that we save the latest final state
-  // to apply in onEnd.
-  private boolean needsSave = false;
+  private final AnimationStateMachine stateMachine;
 
   ImeSyncDeferringInsetsCallback(@NonNull View view) {
     this.view = view;
     this.animationCallback = new AnimationCallback();
     this.insetsListener = new InsetsListener();
+    this.stateMachine = new AnimationStateMachine(deferredInsetTypes);
   }
 
   // Add this object's event listeners to its view.
@@ -91,6 +67,9 @@
   void remove() {
     view.setWindowInsetsAnimationCallback(null);
     view.setOnApplyWindowInsetsListener(null);
+    view = null;
+    imeVisibilityListener = null;
+    stateMachine.reset();
   }
 
   // Set a listener to be notified when the IME visibility changes.
@@ -113,6 +92,11 @@
     return imeVisibilityListener;
   }
 
+  @VisibleForTesting
+  AnimationStateMachine getStateMachine() {
+    return stateMachine;
+  }
+
   // WindowInsetsAnimation.Callback was introduced in API level 30.  The callback
   // subclass is separated into an inner class in order to avoid warnings from
   // the Android class loader on older platforms.
@@ -124,101 +108,42 @@
 
     @Override
     public void onPrepare(WindowInsetsAnimation animation) {
-      needsSave = true;
-      if ((animation.getTypeMask() & deferredInsetTypes) != 0) {
-        animating = true;
+      try (TraceSection e = TraceSection.scoped("ImeSyncDeferringInsetsCallback#onPrepare")) {
+        stateMachine.onPrepare(animation);
       }
     }
 
     @Override
     public WindowInsets onProgress(
         WindowInsets insets, List<WindowInsetsAnimation> runningAnimations) {
-      if (!animating || needsSave) {
-        return insets;
+      try (TraceSection e = TraceSection.scoped("ImeSyncDeferringInsetsCallback#onProgress")) {
+        return stateMachine.onProgress(view, insets, runningAnimations);
       }
-      boolean matching = false;
-      for (WindowInsetsAnimation animation : runningAnimations) {
-        if ((animation.getTypeMask() & deferredInsetTypes) != 0) {
-          matching = true;
-          continue;
-        }
-      }
-      if (!matching) {
-        return insets;
-      }
-
-      // Pre 15, the IME insets include the height of the navigation bar. If the app
-      // isn't laid out behind the navigation bar, this causes the IME insets to be too large during
-      // the animation.  To fix this, we subtract the navigationBars bottom inset if the system UI
-      // flags for laying out behind the navigation bar aren't present.
-      int excludedInsets = 0;
-      int systemUiFlags = view.getWindowSystemUiVisibility();
-      if (Build.VERSION.SDK_INT < API_LEVELS.API_35) {
-        if ((systemUiFlags & View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION) == 0
-            && (systemUiFlags & View.SYSTEM_UI_FLAG_HIDE_NAVIGATION) == 0) {
-          excludedInsets = insets.getInsets(WindowInsets.Type.navigationBars()).bottom;
-        }
-      }
-
-      WindowInsets.Builder builder = new WindowInsets.Builder(lastWindowInsets);
-      Insets newImeInsets =
-          Insets.of(
-              0, 0, 0, Math.max(insets.getInsets(deferredInsetTypes).bottom - excludedInsets, 0));
-      builder.setInsets(deferredInsetTypes, newImeInsets);
-
-      // Directly call onApplyWindowInsets of the view as we do not want to pass through
-      // the onApplyWindowInsets defined in this class, which would consume the insets
-      // as if they were a non-animation inset change and cache it for re-dispatch in
-      // onEnd instead.
-      view.onApplyWindowInsets(builder.build());
-      return insets;
     }
 
     @Override
     public void onEnd(WindowInsetsAnimation animation) {
-      if (animating && (animation.getTypeMask() & deferredInsetTypes) != 0) {
-        // If we deferred the IME insets and an IME animation has finished, we need to reset
-        // the flags
-        animating = false;
-
-        // And finally dispatch the deferred insets to the view now.
-        // Ideally we would just call view.requestApplyInsets() and let the normal dispatch
-        // cycle happen, but this happens too late resulting in a visual flicker.
-        // Instead we manually dispatch the most recent WindowInsets to the view.
-        if (lastWindowInsets != null && view != null) {
-          view.dispatchApplyWindowInsets(lastWindowInsets);
+      try (TraceSection e = TraceSection.scoped("ImeSyncDeferringInsetsCallback#onEnd")) {
+        stateMachine.onEnd(view, animation);
+        if (view != null) {
+          WindowInsetsCompat insets = ViewCompat.getRootWindowInsets(view);
+          if (insets != null && imeVisibilityListener != null) {
+            boolean imeVisible = insets.isVisible(WindowInsetsCompat.Type.ime());
+            imeVisibilityListener.onImeVisibilityChanged(imeVisible);
+          }
         }
       }
-      WindowInsetsCompat insets = ViewCompat.getRootWindowInsets(view);
-      if (insets != null && imeVisibilityListener != null) {
-        boolean imeVisible = insets.isVisible(WindowInsetsCompat.Type.ime());
-        imeVisibilityListener.onImeVisibilityChanged(imeVisible);
-      }
     }
   }
 
   private class InsetsListener implements View.OnApplyWindowInsetsListener {
     @Override
     public WindowInsets onApplyWindowInsets(View view, WindowInsets windowInsets) {
-      ImeSyncDeferringInsetsCallback.this.view = view;
-      if (needsSave) {
-        // Store the view and insets for us in onEnd() below. This captured inset
-        // is not part of the animation and instead, represents the final state
-        // of the inset after the animation is completed. Thus, we defer the processing
-        // of this WindowInset until the animation completes.
-        lastWindowInsets = windowInsets;
-        needsSave = false;
+      try (TraceSection e =
+          TraceSection.scoped("ImeSyncDeferringInsetsCallback#onApplyWindowInsets")) {
+        ImeSyncDeferringInsetsCallback.this.view = view;
+        return stateMachine.onApplyWindowInsets(view, windowInsets);
       }
-      if (animating) {
-        // While animation is running, we consume the insets to prevent disrupting
-        // the animation, which skips this implementation and calls the view's
-        // onApplyWindowInsets directly to avoid being consumed here.
-        return WindowInsets.CONSUMED;
-      }
-
-      // If no animation is happening, pass the insets on to the view's own
-      // inset handling.
-      return view.onApplyWindowInsets(windowInsets);
     }
   }
 
@@ -226,4 +151,153 @@
   public interface ImeVisibilityListener {
     void onImeVisibilityChanged(boolean visible);
   }
+
+  /**
+   * Helper class encapsulating discrete state transitions and mathematics for deferred IME window
+   * insets animations.
+   */
+  @VisibleForTesting
+  static class AnimationStateMachine {
+    enum State {
+      IDLE,
+      PREPARED,
+      ANIMATING,
+    }
+
+    private final int deferredInsetTypes;
+    private State state = State.IDLE;
+    private int startImeBottom = 0;
+    private int currentImeBottom = 0;
+    private WindowInsets lastWindowInsets;
+
+    AnimationStateMachine(int deferredInsetTypes) {
+      this.deferredInsetTypes = deferredInsetTypes;
+    }
+
+    @VisibleForTesting
+    State getState() {
+      return state;
+    }
+
+    @VisibleForTesting
+    int getStartImeBottom() {
+      return startImeBottom;
+    }
+
+    @VisibleForTesting
+    int getCurrentImeBottom() {
+      return currentImeBottom;
+    }
+
+    @VisibleForTesting
+    WindowInsets getLastWindowInsets() {
+      return lastWindowInsets;
+    }
+
+    void onPrepare(WindowInsetsAnimation animation) {
+      if ((animation.getTypeMask() & deferredInsetTypes) != 0) {
+        state = State.PREPARED;
+        startImeBottom = currentImeBottom;
+      } else if (state != State.IDLE) {
+        // If an IME animation is already active or preparing and an accompanying animation
+        // (such as a navigation bar animation during keyboard dismissal) prepares, transition to
+        // PREPARED to capture the updated target WindowInsets in onApplyWindowInsets.
+        state = State.PREPARED;
+      }
+    }
+
+    WindowInsets onApplyWindowInsets(View view, WindowInsets windowInsets) {
+      if (state == State.PREPARED) {
+        // Store the captured target inset for dispatching in onEnd().
+        // Represents the final state of the inset after animation completion.
+        lastWindowInsets = windowInsets;
+        state = State.ANIMATING;
+        return WindowInsets.CONSUMED;
+      }
+      if (state == State.ANIMATING) {
+        // While animation is running, consume insets to prevent disrupting the animation.
+        return WindowInsets.CONSUMED;
+      }
+
+      // State.IDLE: Non-animated insets arrival (e.g. orientation changes, hardware keyboards).
+      currentImeBottom = windowInsets.getInsets(deferredInsetTypes).bottom;
+      lastWindowInsets = windowInsets;
+      return view.onApplyWindowInsets(windowInsets);
+    }
+
+    WindowInsets onProgress(
+        View view, WindowInsets insets, List<WindowInsetsAnimation> runningAnimations) {
+      if (state != State.ANIMATING) {
+        return insets;
+      }
+      WindowInsetsAnimation imeAnimation = null;
+      final int count = runningAnimations.size();
+      for (int i = 0; i < count; i++) {
+        WindowInsetsAnimation animation = runningAnimations.get(i);
+        if ((animation.getTypeMask() & deferredInsetTypes) != 0) {
+          imeAnimation = animation;
+          break;
+        }
+      }
+      if (imeAnimation == null) {
+        return insets;
+      }
+
+      // Rather than guessing whether the OS includes navigation bar insets in raw animated
+      // values across different Android versions and system UI modes, smoothly interpolate
+      // from the initial IME inset to the settled target IME inset captured in lastWindowInsets.
+      // This guarantees mathematical continuity and eliminates any terminal jump in onEnd().
+      int targetImeBottom =
+          lastWindowInsets != null
+              ? lastWindowInsets.getInsets(deferredInsetTypes).bottom
+              : insets.getInsets(deferredInsetTypes).bottom;
+
+      float fraction = imeAnimation.getInterpolatedFraction();
+      int animatedImeBottom =
+          Math.max(0, Math.round(startImeBottom + (targetImeBottom - startImeBottom) * fraction));
+
+      WindowInsets.Builder builder = new WindowInsets.Builder(insets);
+      Insets newImeInsets = Insets.of(0, 0, 0, animatedImeBottom);
+      builder.setInsets(deferredInsetTypes, newImeInsets);
+
+      // Directly call onApplyWindowInsets of the view as we do not want to pass through
+      // the onApplyWindowInsets defined in this class, which would consume the insets
+      // as if they were a non-animation inset change and cache it for re-dispatch in
+      // onEnd instead.
+      if (view != null) {
+        view.onApplyWindowInsets(builder.build());
+      }
+      currentImeBottom = animatedImeBottom;
+      return insets;
+    }
+
+    void onEnd(View view, WindowInsetsAnimation animation) {
+      if ((animation.getTypeMask() & deferredInsetTypes) != 0) {
+        if (state == State.ANIMATING) {
+          int settledBottom =
+              lastWindowInsets != null ? lastWindowInsets.getInsets(deferredInsetTypes).bottom : 0;
+          startImeBottom = settledBottom;
+          currentImeBottom = settledBottom;
+
+          // Transition to IDLE BEFORE dispatching to the view, so that the view's
+          // onApplyWindowInsets listener will pass the settled insets through
+          // rather than consuming them.
+          state = State.IDLE;
+
+          if (lastWindowInsets != null && view != null) {
+            view.dispatchApplyWindowInsets(lastWindowInsets);
+          }
+        } else {
+          state = State.IDLE;
+        }
+      }
+    }
+
+    void reset() {
+      state = State.IDLE;
+      lastWindowInsets = null;
+      startImeBottom = 0;
+      currentImeBottom = 0;
+    }
+  }
 }
diff --git a/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/editing/ImeSyncDeferringInsetsCallbackTest.java b/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/editing/ImeSyncDeferringInsetsCallbackTest.java
new file mode 100644
index 0000000..d073f98
--- /dev/null
+++ b/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/editing/ImeSyncDeferringInsetsCallbackTest.java
@@ -0,0 +1,819 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+package io.flutter.plugin.editing;
+
+import static io.flutter.Build.API_LEVELS;
+import static org.junit.Assert.assertEquals;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.annotation.TargetApi;
+import android.graphics.Insets;
+import android.view.View;
+import android.view.WindowInsets;
+import android.view.WindowInsetsAnimation;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import java.util.Collections;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.robolectric.annotation.Config;
+
+@TargetApi(API_LEVELS.API_30)
+@Config(sdk = {API_LEVELS.API_30, API_LEVELS.API_34})
+@RunWith(AndroidJUnit4.class)
+@SuppressWarnings("deprecation")
+public class ImeSyncDeferringInsetsCallbackTest {
+  // Target settled IME keyboard height in pixels.
+  private static final int FINAL_IME_BOTTOM_INSET = 100;
+  // Navigation bar height in pixels.
+  private static final int NAVIGATION_BAR_BOTTOM_INSET = 40;
+
+  /**
+   * Tests soft keyboard opening animation in modern edge-to-edge mode (API 30-34).
+   *
+   * <p>Rationale / Added for: Fixes flutter/flutter#190974. In modern edge-to-edge mode (where
+   * legacy systemUiVisibility flags are 0), onProgress previously subtracted the navigation bar
+   * height (e.g. 40px) during animation, while onEnd dispatched the full target insets (100px),
+   * causing a visible terminal jump. This test verifies that onProgress smoothly interpolates
+   * towards the settled target inset (100px) so that at fraction 1.0f the animated insets exactly
+   * match the onEnd settled insets.
+   */
+  @SuppressWarnings("deprecation")
+  // getWindowSystemUiVisibility.
+  @Test
+  public void imeAnimation_edgeToEdgeOpening_smoothlyInterpolatesToFinalValue() {
+    View view = mock(View.class);
+    // Legacy flags are 0 in modern edge-to-edge mode.
+    when(view.getWindowSystemUiVisibility()).thenReturn(0);
+
+    ImeSyncDeferringInsetsCallback callback = new ImeSyncDeferringInsetsCallback(view);
+    WindowInsetsAnimation animation = mock(WindowInsetsAnimation.class);
+    when(animation.getTypeMask()).thenReturn(WindowInsets.Type.ime());
+
+    callback.getAnimationCallback().onPrepare(animation);
+    WindowInsets finalInsets =
+        new WindowInsets.Builder()
+            .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, FINAL_IME_BOTTOM_INSET))
+            .setInsets(
+                WindowInsets.Type.navigationBars(), Insets.of(0, 0, 0, NAVIGATION_BAR_BOTTOM_INSET))
+            .build();
+    callback.getInsetsListener().onApplyWindowInsets(view, finalInsets);
+
+    // At 50% fraction (progress = 0.5f).
+    when(animation.getInterpolatedFraction()).thenReturn(0.5f);
+    WindowInsets midProgressInsets =
+        new WindowInsets.Builder()
+            .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, FINAL_IME_BOTTOM_INSET / 2))
+            .setInsets(
+                WindowInsets.Type.navigationBars(), Insets.of(0, 0, 0, NAVIGATION_BAR_BOTTOM_INSET))
+            .build();
+    callback
+        .getAnimationCallback()
+        .onProgress(midProgressInsets, Collections.singletonList(animation));
+
+    ArgumentCaptor<WindowInsets> midInsetsCaptor = ArgumentCaptor.forClass(WindowInsets.class);
+    verify(view).onApplyWindowInsets(midInsetsCaptor.capture());
+    // 50% of 100px target = 50px.
+    assertEquals(50, midInsetsCaptor.getValue().getInsets(WindowInsets.Type.ime()).bottom);
+
+    // At 100% fraction (terminal progress = 1.0f).
+    when(animation.getInterpolatedFraction()).thenReturn(1.0f);
+    WindowInsets terminalProgressInsets =
+        new WindowInsets.Builder()
+            .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, FINAL_IME_BOTTOM_INSET))
+            .setInsets(
+                WindowInsets.Type.navigationBars(), Insets.of(0, 0, 0, NAVIGATION_BAR_BOTTOM_INSET))
+            .build();
+    callback
+        .getAnimationCallback()
+        .onProgress(terminalProgressInsets, Collections.singletonList(animation));
+
+    ArgumentCaptor<WindowInsets> terminalInsetsCaptor = ArgumentCaptor.forClass(WindowInsets.class);
+    verify(view, org.mockito.Mockito.times(2)).onApplyWindowInsets(terminalInsetsCaptor.capture());
+    // At 1.0f fraction, terminal progress must equal the settled value (100px) with 0 jump.
+    assertEquals(
+        FINAL_IME_BOTTOM_INSET,
+        terminalInsetsCaptor.getValue().getInsets(WindowInsets.Type.ime()).bottom);
+
+    // When onEnd is called, the dispatched insets must match terminal progress.
+    callback.getAnimationCallback().onEnd(animation);
+    ArgumentCaptor<WindowInsets> endInsetsCaptor = ArgumentCaptor.forClass(WindowInsets.class);
+    verify(view).dispatchApplyWindowInsets(endInsetsCaptor.capture());
+    assertEquals(
+        FINAL_IME_BOTTOM_INSET,
+        endInsetsCaptor.getValue().getInsets(WindowInsets.Type.ime()).bottom);
+  }
+
+  /**
+   * Tests soft keyboard opening animation in standard non-edge-to-edge mode.
+   *
+   * <p>Rationale / Added for: Verifies that in non-edge-to-edge mode, target-inset interpolation
+   * smoothly scales from 0 to the target IME inset without jumping or clipping, regardless of raw
+   * OS animated insets.
+   */
+  @SuppressWarnings("deprecation")
+  // getWindowSystemUiVisibility.
+  @Test
+  public void imeAnimation_nonEdgeToEdgeOpening_smoothlyInterpolatesToFinalValue() {
+    View view = mock(View.class);
+    // Legacy flags are 0 in non-edge-to-edge mode.
+    when(view.getWindowSystemUiVisibility()).thenReturn(0);
+
+    ImeSyncDeferringInsetsCallback callback = new ImeSyncDeferringInsetsCallback(view);
+    WindowInsetsAnimation animation = mock(WindowInsetsAnimation.class);
+    when(animation.getTypeMask()).thenReturn(WindowInsets.Type.ime());
+
+    callback.getAnimationCallback().onPrepare(animation);
+    // In non-edge-to-edge mode, settled final inset is FINAL_IME_BOTTOM_INSET (e.g. 100px).
+    WindowInsets finalInsets =
+        new WindowInsets.Builder()
+            .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, FINAL_IME_BOTTOM_INSET))
+            .build();
+    callback.getInsetsListener().onApplyWindowInsets(view, finalInsets);
+
+    // At 50% fraction (progress = 0.5f).
+    when(animation.getInterpolatedFraction()).thenReturn(0.5f);
+    WindowInsets midProgressInsets =
+        new WindowInsets.Builder()
+            .setInsets(
+                WindowInsets.Type.ime(),
+                Insets.of(0, 0, 0, (FINAL_IME_BOTTOM_INSET + NAVIGATION_BAR_BOTTOM_INSET) / 2))
+            .build();
+    callback
+        .getAnimationCallback()
+        .onProgress(midProgressInsets, Collections.singletonList(animation));
+
+    ArgumentCaptor<WindowInsets> midInsetsCaptor = ArgumentCaptor.forClass(WindowInsets.class);
+    verify(view).onApplyWindowInsets(midInsetsCaptor.capture());
+    assertEquals(50, midInsetsCaptor.getValue().getInsets(WindowInsets.Type.ime()).bottom);
+
+    // At 100% fraction (progress = 1.0f).
+    when(animation.getInterpolatedFraction()).thenReturn(1.0f);
+    WindowInsets terminalProgressInsets =
+        new WindowInsets.Builder()
+            .setInsets(
+                WindowInsets.Type.ime(),
+                Insets.of(0, 0, 0, FINAL_IME_BOTTOM_INSET + NAVIGATION_BAR_BOTTOM_INSET))
+            .build();
+    callback
+        .getAnimationCallback()
+        .onProgress(terminalProgressInsets, Collections.singletonList(animation));
+
+    ArgumentCaptor<WindowInsets> terminalInsetsCaptor = ArgumentCaptor.forClass(WindowInsets.class);
+    verify(view, org.mockito.Mockito.times(2)).onApplyWindowInsets(terminalInsetsCaptor.capture());
+    assertEquals(
+        FINAL_IME_BOTTOM_INSET,
+        terminalInsetsCaptor.getValue().getInsets(WindowInsets.Type.ime()).bottom);
+
+    callback.getAnimationCallback().onEnd(animation);
+    ArgumentCaptor<WindowInsets> endInsetsCaptor = ArgumentCaptor.forClass(WindowInsets.class);
+    verify(view).dispatchApplyWindowInsets(endInsetsCaptor.capture());
+    assertEquals(
+        FINAL_IME_BOTTOM_INSET,
+        endInsetsCaptor.getValue().getInsets(WindowInsets.Type.ime()).bottom);
+  }
+
+  /**
+   * Tests soft keyboard opening animation with legacy SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION flags.
+   *
+   * <p>Rationale / Added for: Ensures backward compatibility with legacy apps that explicitly
+   * configure View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION on their root view.
+   */
+  @SuppressWarnings("deprecation")
+  // SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION, getWindowSystemUiVisibility.
+  @Test
+  public void imeAnimation_legacyEdgeToEdgeOpening_smoothlyInterpolatesToFinalValue() {
+    View view = mock(View.class);
+    when(view.getWindowSystemUiVisibility()).thenReturn(View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION);
+
+    ImeSyncDeferringInsetsCallback callback = new ImeSyncDeferringInsetsCallback(view);
+    WindowInsetsAnimation animation = mock(WindowInsetsAnimation.class);
+    when(animation.getTypeMask()).thenReturn(WindowInsets.Type.ime());
+
+    callback.getAnimationCallback().onPrepare(animation);
+    WindowInsets finalInsets =
+        new WindowInsets.Builder()
+            .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, FINAL_IME_BOTTOM_INSET))
+            .setInsets(
+                WindowInsets.Type.navigationBars(), Insets.of(0, 0, 0, NAVIGATION_BAR_BOTTOM_INSET))
+            .build();
+    callback.getInsetsListener().onApplyWindowInsets(view, finalInsets);
+
+    when(animation.getInterpolatedFraction()).thenReturn(1.0f);
+    WindowInsets terminalProgressInsets =
+        new WindowInsets.Builder()
+            .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, FINAL_IME_BOTTOM_INSET))
+            .setInsets(
+                WindowInsets.Type.navigationBars(), Insets.of(0, 0, 0, NAVIGATION_BAR_BOTTOM_INSET))
+            .build();
+    callback
+        .getAnimationCallback()
+        .onProgress(terminalProgressInsets, Collections.singletonList(animation));
+
+    ArgumentCaptor<WindowInsets> terminalInsetsCaptor = ArgumentCaptor.forClass(WindowInsets.class);
+    verify(view).onApplyWindowInsets(terminalInsetsCaptor.capture());
+    assertEquals(
+        FINAL_IME_BOTTOM_INSET,
+        terminalInsetsCaptor.getValue().getInsets(WindowInsets.Type.ime()).bottom);
+
+    callback.getAnimationCallback().onEnd(animation);
+    ArgumentCaptor<WindowInsets> endInsetsCaptor = ArgumentCaptor.forClass(WindowInsets.class);
+    verify(view).dispatchApplyWindowInsets(endInsetsCaptor.capture());
+    assertEquals(
+        FINAL_IME_BOTTOM_INSET,
+        endInsetsCaptor.getValue().getInsets(WindowInsets.Type.ime()).bottom);
+  }
+
+  /**
+   * Tests soft keyboard dismissal (closing) animation.
+   *
+   * <p>Rationale / Added for: Verifies that during dismissal, animated insets interpolate smoothly
+   * from the established keyboard height (100px) down to 0px without dropping by the navigation bar
+   * height on frame 0.
+   */
+  @SuppressWarnings("deprecation")
+  // getWindowSystemUiVisibility.
+  @Test
+  public void imeAnimation_closing_smoothlyInterpolatesToZero() {
+    View view = mock(View.class);
+    when(view.getWindowSystemUiVisibility()).thenReturn(0);
+
+    ImeSyncDeferringInsetsCallback callback = new ImeSyncDeferringInsetsCallback(view);
+    WindowInsetsAnimation openAnimation = mock(WindowInsetsAnimation.class);
+    when(openAnimation.getTypeMask()).thenReturn(WindowInsets.Type.ime());
+
+    // First, open the keyboard to establish initial settled insets.
+    callback.getAnimationCallback().onPrepare(openAnimation);
+    WindowInsets openInsets =
+        new WindowInsets.Builder()
+            .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, FINAL_IME_BOTTOM_INSET))
+            .build();
+    callback.getInsetsListener().onApplyWindowInsets(view, openInsets);
+    when(openAnimation.getInterpolatedFraction()).thenReturn(1.0f);
+    callback
+        .getAnimationCallback()
+        .onProgress(openInsets, Collections.singletonList(openAnimation));
+    callback.getAnimationCallback().onEnd(openAnimation);
+
+    // Now start closing animation.
+    WindowInsetsAnimation closeAnimation = mock(WindowInsetsAnimation.class);
+    when(closeAnimation.getTypeMask()).thenReturn(WindowInsets.Type.ime());
+    callback.getAnimationCallback().onPrepare(closeAnimation);
+    WindowInsets closedInsets =
+        new WindowInsets.Builder()
+            .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 0))
+            .build();
+    callback.getInsetsListener().onApplyWindowInsets(view, closedInsets);
+
+    // At 50% closing (fraction = 0.5f).
+    when(closeAnimation.getInterpolatedFraction()).thenReturn(0.5f);
+    WindowInsets midCloseInsets =
+        new WindowInsets.Builder()
+            .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, FINAL_IME_BOTTOM_INSET / 2))
+            .build();
+    callback
+        .getAnimationCallback()
+        .onProgress(midCloseInsets, Collections.singletonList(closeAnimation));
+
+    ArgumentCaptor<WindowInsets> midCloseCaptor = ArgumentCaptor.forClass(WindowInsets.class);
+    verify(view, org.mockito.Mockito.atLeastOnce()).onApplyWindowInsets(midCloseCaptor.capture());
+    // 50% closed from 100px = 50px.
+    assertEquals(50, midCloseCaptor.getValue().getInsets(WindowInsets.Type.ime()).bottom);
+
+    // At 100% closing (fraction = 1.0f).
+    when(closeAnimation.getInterpolatedFraction()).thenReturn(1.0f);
+    WindowInsets finalCloseInsets =
+        new WindowInsets.Builder()
+            .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 0))
+            .build();
+    callback
+        .getAnimationCallback()
+        .onProgress(finalCloseInsets, Collections.singletonList(closeAnimation));
+
+    ArgumentCaptor<WindowInsets> finalCloseCaptor = ArgumentCaptor.forClass(WindowInsets.class);
+    verify(view, org.mockito.Mockito.atLeastOnce()).onApplyWindowInsets(finalCloseCaptor.capture());
+    assertEquals(0, finalCloseCaptor.getValue().getInsets(WindowInsets.Type.ime()).bottom);
+
+    callback.getAnimationCallback().onEnd(closeAnimation);
+    ArgumentCaptor<WindowInsets> endInsetsCaptor = ArgumentCaptor.forClass(WindowInsets.class);
+    verify(view, org.mockito.Mockito.atLeastOnce())
+        .dispatchApplyWindowInsets(endInsetsCaptor.capture());
+    assertEquals(0, endInsetsCaptor.getValue().getInsets(WindowInsets.Type.ime()).bottom);
+  }
+
+  /**
+   * Tests interrupted animations (e.g. keyboard closing gesture triggered while opening is
+   * in-flight).
+   *
+   * <p>If startImeBottom was read from lastWindowInsets (which stored the prior target of 100px),
+   * interrupting an opening animation at 50px would calculate 100 + (0 - 100) * fraction, snapping
+   * instantly from 50px up to 100px on frame 0 of the closing animation. This test verifies that
+   * tracking currentImeBottom enables smooth interpolation from the in-flight inset (50px) down to
+   * 0px (reaching 25px at 50% closing).
+   */
+  @Test
+  public void imeAnimation_interruptedAnimation_smoothlyInterpolatesFromInFlightInset() {
+    View view = mock(View.class);
+    when(view.getWindowSystemUiVisibility()).thenReturn(0);
+
+    ImeSyncDeferringInsetsCallback callback = new ImeSyncDeferringInsetsCallback(view);
+    WindowInsetsAnimation openAnimation = mock(WindowInsetsAnimation.class);
+    when(openAnimation.getTypeMask()).thenReturn(WindowInsets.Type.ime());
+
+    // 1. Start opening animation towards 100px.
+    callback.getAnimationCallback().onPrepare(openAnimation);
+    WindowInsets openTargetInsets =
+        new WindowInsets.Builder()
+            .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, FINAL_IME_BOTTOM_INSET))
+            .build();
+    callback.getInsetsListener().onApplyWindowInsets(view, openTargetInsets);
+
+    // Advance opening animation to 50% (fraction = 0.5f -> in-flight = 50px).
+    when(openAnimation.getInterpolatedFraction()).thenReturn(0.5f);
+    WindowInsets midOpenInsets =
+        new WindowInsets.Builder()
+            .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 50))
+            .build();
+    callback
+        .getAnimationCallback()
+        .onProgress(midOpenInsets, Collections.singletonList(openAnimation));
+
+    ArgumentCaptor<WindowInsets> midOpenCaptor = ArgumentCaptor.forClass(WindowInsets.class);
+    verify(view).onApplyWindowInsets(midOpenCaptor.capture());
+    assertEquals(50, midOpenCaptor.getValue().getInsets(WindowInsets.Type.ime()).bottom);
+
+    // 2. Interrupt mid-flight with a closing animation towards 0px (without onEnd of the open).
+    WindowInsetsAnimation interruptCloseAnimation = mock(WindowInsetsAnimation.class);
+    when(interruptCloseAnimation.getTypeMask()).thenReturn(WindowInsets.Type.ime());
+    callback.getAnimationCallback().onPrepare(interruptCloseAnimation);
+
+    WindowInsets closeTargetInsets =
+        new WindowInsets.Builder()
+            .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 0))
+            .build();
+    callback.getInsetsListener().onApplyWindowInsets(view, closeTargetInsets);
+
+    // At 50% fraction of the closing animation, it must smoothly interpolate
+    // from the in-flight starting inset (50px) down to 0px: 50 + (0 - 50) * 0.5 = 25px!
+    when(interruptCloseAnimation.getInterpolatedFraction()).thenReturn(0.5f);
+    WindowInsets midInterruptCloseInsets =
+        new WindowInsets.Builder()
+            .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 25))
+            .build();
+    callback
+        .getAnimationCallback()
+        .onProgress(midInterruptCloseInsets, Collections.singletonList(interruptCloseAnimation));
+
+    ArgumentCaptor<WindowInsets> midInterruptCaptor = ArgumentCaptor.forClass(WindowInsets.class);
+    verify(view, org.mockito.Mockito.times(2)).onApplyWindowInsets(midInterruptCaptor.capture());
+    assertEquals(25, midInterruptCaptor.getValue().getInsets(WindowInsets.Type.ime()).bottom);
+
+    // At 100% closing, reaches 0px.
+    when(interruptCloseAnimation.getInterpolatedFraction()).thenReturn(1.0f);
+    WindowInsets finalInterruptCloseInsets =
+        new WindowInsets.Builder()
+            .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 0))
+            .build();
+    callback
+        .getAnimationCallback()
+        .onProgress(finalInterruptCloseInsets, Collections.singletonList(interruptCloseAnimation));
+
+    ArgumentCaptor<WindowInsets> finalInterruptCaptor = ArgumentCaptor.forClass(WindowInsets.class);
+    verify(view, org.mockito.Mockito.times(3)).onApplyWindowInsets(finalInterruptCaptor.capture());
+    assertEquals(0, finalInterruptCaptor.getValue().getInsets(WindowInsets.Type.ime()).bottom);
+
+    callback.getAnimationCallback().onEnd(interruptCloseAnimation);
+    ArgumentCaptor<WindowInsets> endInsetsCaptor = ArgumentCaptor.forClass(WindowInsets.class);
+    verify(view).dispatchApplyWindowInsets(endInsetsCaptor.capture());
+    assertEquals(0, endInsetsCaptor.getValue().getInsets(WindowInsets.Type.ime()).bottom);
+  }
+
+  /**
+   * Tests aborted animation lifecycles (onPrepare immediately followed by onEnd without
+   * onApplyWindowInsets).
+   *
+   * <p>If onPrepare sets needsSave = true and the animation is aborted before onApplyWindowInsets
+   * fires, onEnd must reset needsSave = false so subsequent animations are not stalled by dropped
+   * onProgress frames.
+   */
+  @Test
+  public void imeAnimation_abortedLifecycle_resetsNeedsSaveAndAllowsSubsequentAnimations() {
+    View view = mock(View.class);
+    when(view.getWindowSystemUiVisibility()).thenReturn(0);
+
+    ImeSyncDeferringInsetsCallback callback = new ImeSyncDeferringInsetsCallback(view);
+    WindowInsetsAnimation abortedAnimation = mock(WindowInsetsAnimation.class);
+    when(abortedAnimation.getTypeMask()).thenReturn(WindowInsets.Type.ime());
+
+    // 1. Animation prepared but immediately ended without onApplyWindowInsets or onProgress.
+    callback.getAnimationCallback().onPrepare(abortedAnimation);
+    callback.getAnimationCallback().onEnd(abortedAnimation);
+
+    // 2. Subsequent animation should not be blocked or stalled by stale needsSave state.
+    WindowInsetsAnimation nextAnimation = mock(WindowInsetsAnimation.class);
+    when(nextAnimation.getTypeMask()).thenReturn(WindowInsets.Type.ime());
+
+    callback.getAnimationCallback().onPrepare(nextAnimation);
+    WindowInsets targetInsets =
+        new WindowInsets.Builder()
+            .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, FINAL_IME_BOTTOM_INSET))
+            .build();
+    callback.getInsetsListener().onApplyWindowInsets(view, targetInsets);
+
+    when(nextAnimation.getInterpolatedFraction()).thenReturn(1.0f);
+    callback
+        .getAnimationCallback()
+        .onProgress(targetInsets, Collections.singletonList(nextAnimation));
+
+    ArgumentCaptor<WindowInsets> progressCaptor = ArgumentCaptor.forClass(WindowInsets.class);
+    verify(view).onApplyWindowInsets(progressCaptor.capture());
+    assertEquals(
+        FINAL_IME_BOTTOM_INSET,
+        progressCaptor.getValue().getInsets(WindowInsets.Type.ime()).bottom);
+
+    callback.getAnimationCallback().onEnd(nextAnimation);
+    ArgumentCaptor<WindowInsets> endInsetsCaptor = ArgumentCaptor.forClass(WindowInsets.class);
+    verify(view).dispatchApplyWindowInsets(endInsetsCaptor.capture());
+    assertEquals(
+        FINAL_IME_BOTTOM_INSET,
+        endInsetsCaptor.getValue().getInsets(WindowInsets.Type.ime()).bottom);
+  }
+
+  /**
+   * Tests concurrent animation of IME and other system bars (status bars / navigation bars).
+   *
+   * <p>If WindowInsets.Builder used a stale lastWindowInsets snapshot, freezing non-IME system bar
+   * animations during IME transitions. This test verifies that non-IME insets from the current
+   * frame's insets object are preserved.
+   */
+  @Test
+  public void imeAnimation_concurrentSystemBarAnimations_preservesNonImeInsets() {
+    View view = mock(View.class);
+    when(view.getWindowSystemUiVisibility()).thenReturn(0);
+
+    ImeSyncDeferringInsetsCallback callback = new ImeSyncDeferringInsetsCallback(view);
+    WindowInsetsAnimation imeAnimation = mock(WindowInsetsAnimation.class);
+    when(imeAnimation.getTypeMask()).thenReturn(WindowInsets.Type.ime());
+
+    callback.getAnimationCallback().onPrepare(imeAnimation);
+    WindowInsets initialInsets =
+        new WindowInsets.Builder()
+            .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, FINAL_IME_BOTTOM_INSET))
+            .setInsets(WindowInsets.Type.statusBars(), Insets.of(0, 50, 0, 0))
+            .setInsets(WindowInsets.Type.navigationBars(), Insets.of(0, 0, 0, 40))
+            .build();
+    callback.getInsetsListener().onApplyWindowInsets(view, initialInsets);
+
+    // During onProgress, status bar insets change concurrently from 50px to 60px.
+    when(imeAnimation.getInterpolatedFraction()).thenReturn(0.5f);
+    WindowInsets progressInsetsWithChangedStatusBar =
+        new WindowInsets.Builder()
+            .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 50))
+            .setInsets(WindowInsets.Type.statusBars(), Insets.of(0, 60, 0, 0))
+            .setInsets(WindowInsets.Type.navigationBars(), Insets.of(0, 0, 0, 40))
+            .build();
+    callback
+        .getAnimationCallback()
+        .onProgress(progressInsetsWithChangedStatusBar, Collections.singletonList(imeAnimation));
+
+    ArgumentCaptor<WindowInsets> progressCaptor = ArgumentCaptor.forClass(WindowInsets.class);
+    verify(view).onApplyWindowInsets(progressCaptor.capture());
+
+    // IME insets interpolated to 50px.
+    assertEquals(50, progressCaptor.getValue().getInsets(WindowInsets.Type.ime()).bottom);
+    // Non-IME status bar insets from the current frame (60px) are preserved and not locked to 50px.
+    assertEquals(60, progressCaptor.getValue().getInsets(WindowInsets.Type.statusBars()).top);
+  }
+
+  /**
+   * Tests arrival of non-animated window insets while !animating.
+   *
+   * <p>When window insets arrive without an animation (e.g. initial layout, orientation change,
+   * split screen, or hardware keyboard toggle), onApplyWindowInsets must update currentImeBottom
+   * and lastWindowInsets. If not updated, currentImeBottom remains 0, causing any subsequent
+   * animated dismissal to start from 0 and immediately collapse on frame 1.
+   */
+  @Test
+  public void
+      imeAnimation_nonAnimatedInsetsArrival_synchronizesCurrentImeBottomAndAllowsSubsequentAnimatedDismissal() {
+    View view = mock(View.class);
+    when(view.getWindowSystemUiVisibility()).thenReturn(0);
+
+    ImeSyncDeferringInsetsCallback callback = new ImeSyncDeferringInsetsCallback(view);
+
+    // 1. Non-animated insets arrive (e.g. keyboard shown instantly or orientation changed).
+    WindowInsets nonAnimatedInsets =
+        new WindowInsets.Builder()
+            .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, FINAL_IME_BOTTOM_INSET))
+            .build();
+    callback.getInsetsListener().onApplyWindowInsets(view, nonAnimatedInsets);
+
+    // 2. An animated dismissal is initiated.
+    WindowInsetsAnimation closeAnimation = mock(WindowInsetsAnimation.class);
+    when(closeAnimation.getTypeMask()).thenReturn(WindowInsets.Type.ime());
+    callback.getAnimationCallback().onPrepare(closeAnimation);
+
+    WindowInsets closeTargetInsets =
+        new WindowInsets.Builder()
+            .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 0))
+            .build();
+    callback.getInsetsListener().onApplyWindowInsets(view, closeTargetInsets);
+
+    // At 50% fraction, animated insets must smoothly interpolate from 100px down to 0px (reaching
+    // 50px).
+    // Without synchronizing currentImeBottom, startImeBottom would have been 0px and output 0px.
+    when(closeAnimation.getInterpolatedFraction()).thenReturn(0.5f);
+    WindowInsets midCloseInsets =
+        new WindowInsets.Builder()
+            .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 50))
+            .build();
+    callback
+        .getAnimationCallback()
+        .onProgress(midCloseInsets, Collections.singletonList(closeAnimation));
+
+    ArgumentCaptor<WindowInsets> midCloseCaptor = ArgumentCaptor.forClass(WindowInsets.class);
+    verify(view, org.mockito.Mockito.atLeastOnce()).onApplyWindowInsets(midCloseCaptor.capture());
+    assertEquals(50, midCloseCaptor.getValue().getInsets(WindowInsets.Type.ime()).bottom);
+
+    // Dismissal reaches 0px at fraction 1.0f.
+    when(closeAnimation.getInterpolatedFraction()).thenReturn(1.0f);
+    WindowInsets terminalCloseInsets =
+        new WindowInsets.Builder()
+            .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 0))
+            .build();
+    callback
+        .getAnimationCallback()
+        .onProgress(terminalCloseInsets, Collections.singletonList(closeAnimation));
+
+    callback.getAnimationCallback().onEnd(closeAnimation);
+    ArgumentCaptor<WindowInsets> endInsetsCaptor = ArgumentCaptor.forClass(WindowInsets.class);
+    verify(view, org.mockito.Mockito.atLeastOnce())
+        .dispatchApplyWindowInsets(endInsetsCaptor.capture());
+    assertEquals(0, endInsetsCaptor.getValue().getInsets(WindowInsets.Type.ime()).bottom);
+  }
+
+  /**
+   * Tests non-IME animation preparation (e.g. status bar / caption bar animations).
+   *
+   * <p>onPrepare for non-IME animations must not set needsSave = true, ensuring that running IME
+   * animations are not stalled or interrupted.
+   */
+  @Test
+  public void imeAnimation_nonImeAnimationPrepare_doesNotSetNeedsSaveOrStallRunningImeAnimations() {
+    View view = mock(View.class);
+    when(view.getWindowSystemUiVisibility()).thenReturn(0);
+
+    ImeSyncDeferringInsetsCallback callback = new ImeSyncDeferringInsetsCallback(view);
+
+    // 1. IME animation prepared and started.
+    WindowInsetsAnimation imeAnimation = mock(WindowInsetsAnimation.class);
+    when(imeAnimation.getTypeMask()).thenReturn(WindowInsets.Type.ime());
+    callback.getAnimationCallback().onPrepare(imeAnimation);
+
+    WindowInsets imeTargetInsets =
+        new WindowInsets.Builder()
+            .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, FINAL_IME_BOTTOM_INSET))
+            .build();
+    callback.getInsetsListener().onApplyWindowInsets(view, imeTargetInsets);
+
+    // 2. A non-IME animation (e.g. status bar transition) prepares during IME animation,
+    // followed by target insets delivery per Android WindowInsetsAnimation contract.
+    WindowInsetsAnimation statusBarAnimation = mock(WindowInsetsAnimation.class);
+    when(statusBarAnimation.getTypeMask()).thenReturn(WindowInsets.Type.statusBars());
+    callback.getAnimationCallback().onPrepare(statusBarAnimation);
+    callback.getInsetsListener().onApplyWindowInsets(view, imeTargetInsets);
+
+    // 3. onProgress for IME animation smoothly continues after target insets capture.
+    when(imeAnimation.getInterpolatedFraction()).thenReturn(0.5f);
+    WindowInsets midProgressInsets =
+        new WindowInsets.Builder()
+            .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 50))
+            .build();
+    callback
+        .getAnimationCallback()
+        .onProgress(midProgressInsets, Collections.singletonList(imeAnimation));
+
+    ArgumentCaptor<WindowInsets> progressCaptor = ArgumentCaptor.forClass(WindowInsets.class);
+    verify(view).onApplyWindowInsets(progressCaptor.capture());
+    assertEquals(50, progressCaptor.getValue().getInsets(WindowInsets.Type.ime()).bottom);
+  }
+
+  /**
+   * Tests cleanup of callbacks and internal state in remove().
+   *
+   * <p>Rationale / Added for: Verifies that unhooking listeners from the view resets all internal
+   * state flags (animating, needsSave, startImeBottom, currentImeBottom) to prevent stale state on
+   * re-install.
+   */
+  @Test
+  public void imeAnimation_remove_cleansUpAllState() {
+    View view = mock(View.class);
+    ImeSyncDeferringInsetsCallback callback = new ImeSyncDeferringInsetsCallback(view);
+    callback.install();
+    verify(view).setWindowInsetsAnimationCallback(callback.getAnimationCallback());
+    verify(view).setOnApplyWindowInsetsListener(callback.getInsetsListener());
+
+    callback.remove();
+    verify(view).setWindowInsetsAnimationCallback(null);
+    verify(view).setOnApplyWindowInsetsListener(null);
+    assertEquals(
+        ImeSyncDeferringInsetsCallback.AnimationStateMachine.State.IDLE,
+        callback.getStateMachine().getState());
+    assertEquals(0, callback.getStateMachine().getStartImeBottom());
+    assertEquals(0, callback.getStateMachine().getCurrentImeBottom());
+  }
+
+  /**
+   * Tests discrete state transitions of AnimationStateMachine across a full animation lifecycle.
+   *
+   * <p>Rationale / Added for: Validates the encapsulated State Machine helper class transitions
+   * explicitly: IDLE -> PREPARED -> ANIMATING -> IDLE.
+   */
+  @Test
+  public void stateMachine_fullLifecycleTransitions() {
+    View view = mock(View.class);
+    ImeSyncDeferringInsetsCallback.AnimationStateMachine stateMachine =
+        new ImeSyncDeferringInsetsCallback.AnimationStateMachine(WindowInsets.Type.ime());
+
+    // 1. Initial State: IDLE
+    assertEquals(
+        ImeSyncDeferringInsetsCallback.AnimationStateMachine.State.IDLE, stateMachine.getState());
+
+    // 2. onPrepare -> PREPARED
+    WindowInsetsAnimation imeAnimation = mock(WindowInsetsAnimation.class);
+    when(imeAnimation.getTypeMask()).thenReturn(WindowInsets.Type.ime());
+    stateMachine.onPrepare(imeAnimation);
+    assertEquals(
+        ImeSyncDeferringInsetsCallback.AnimationStateMachine.State.PREPARED,
+        stateMachine.getState());
+
+    // 3. onApplyWindowInsets -> ANIMATING
+    WindowInsets targetInsets =
+        new WindowInsets.Builder()
+            .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, FINAL_IME_BOTTOM_INSET))
+            .build();
+    WindowInsets consumedResult = stateMachine.onApplyWindowInsets(view, targetInsets);
+    assertEquals(WindowInsets.CONSUMED, consumedResult);
+    assertEquals(
+        ImeSyncDeferringInsetsCallback.AnimationStateMachine.State.ANIMATING,
+        stateMachine.getState());
+
+    // 4. onProgress -> remains ANIMATING
+    when(imeAnimation.getInterpolatedFraction()).thenReturn(0.5f);
+    WindowInsets progressInsets =
+        new WindowInsets.Builder()
+            .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 50))
+            .build();
+    stateMachine.onProgress(view, progressInsets, Collections.singletonList(imeAnimation));
+    assertEquals(
+        ImeSyncDeferringInsetsCallback.AnimationStateMachine.State.ANIMATING,
+        stateMachine.getState());
+    assertEquals(50, stateMachine.getCurrentImeBottom());
+
+    // 5. onEnd -> settles and transitions to IDLE
+    stateMachine.onEnd(view, imeAnimation);
+    assertEquals(
+        ImeSyncDeferringInsetsCallback.AnimationStateMachine.State.IDLE, stateMachine.getState());
+    assertEquals(100, stateMachine.getCurrentImeBottom());
+  }
+
+  /**
+   * Tests discrete state transition when an animation is aborted before onApplyWindowInsets.
+   *
+   * <p>Rationale / Added for: Verifies that onEnd while in PREPARED state resets cleanly to IDLE
+   * without applying stale insets.
+   */
+  @Test
+  public void stateMachine_abortedLifecycle_resetsToIdle() {
+    View view = mock(View.class);
+    ImeSyncDeferringInsetsCallback.AnimationStateMachine stateMachine =
+        new ImeSyncDeferringInsetsCallback.AnimationStateMachine(WindowInsets.Type.ime());
+
+    WindowInsetsAnimation imeAnimation = mock(WindowInsetsAnimation.class);
+    when(imeAnimation.getTypeMask()).thenReturn(WindowInsets.Type.ime());
+
+    stateMachine.onPrepare(imeAnimation);
+    assertEquals(
+        ImeSyncDeferringInsetsCallback.AnimationStateMachine.State.PREPARED,
+        stateMachine.getState());
+
+    // Aborted: onEnd called immediately
+    stateMachine.onEnd(view, imeAnimation);
+    assertEquals(
+        ImeSyncDeferringInsetsCallback.AnimationStateMachine.State.IDLE, stateMachine.getState());
+  }
+
+  /**
+   * Tests that onEnd transitions to IDLE before invoking view.dispatchApplyWindowInsets.
+   *
+   * <p>In a real Android View hierarchy, view.dispatchApplyWindowInsets immediately forwards to the
+   * view's OnApplyWindowInsetsListener (the callback's insets listener). If state is still
+   * ANIMATING, onApplyWindowInsets returns CONSUMED and drops the final insets. Transitioning to
+   * IDLE before dispatching ensures the settled insets are delivered to view.onApplyWindowInsets.
+   */
+  @Test
+  public void stateMachine_onEnd_transitionsToIdleBeforeDispatchingSoViewReceivesSettledInsets() {
+    View view = mock(View.class);
+    ImeSyncDeferringInsetsCallback.AnimationStateMachine stateMachine =
+        new ImeSyncDeferringInsetsCallback.AnimationStateMachine(WindowInsets.Type.ime());
+
+    WindowInsetsAnimation imeAnimation = mock(WindowInsetsAnimation.class);
+    when(imeAnimation.getTypeMask()).thenReturn(WindowInsets.Type.ime());
+
+    // 1. Prepare and capture target insets
+    stateMachine.onPrepare(imeAnimation);
+    WindowInsets targetInsets =
+        new WindowInsets.Builder()
+            .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, FINAL_IME_BOTTOM_INSET))
+            .build();
+    stateMachine.onApplyWindowInsets(view, targetInsets);
+    assertEquals(
+        ImeSyncDeferringInsetsCallback.AnimationStateMachine.State.ANIMATING,
+        stateMachine.getState());
+
+    // 2. Simulate Android framework forwarding: dispatchApplyWindowInsets invokes
+    // onApplyWindowInsets
+    org.mockito.Mockito.doAnswer(
+            invocation -> {
+              WindowInsets insets = invocation.getArgument(0);
+              return stateMachine.onApplyWindowInsets(view, insets);
+            })
+        .when(view)
+        .dispatchApplyWindowInsets(org.mockito.ArgumentMatchers.any(WindowInsets.class));
+
+    // 3. onEnd is called
+    stateMachine.onEnd(view, imeAnimation);
+
+    // State is IDLE, and view.onApplyWindowInsets was invoked with targetInsets
+    assertEquals(
+        ImeSyncDeferringInsetsCallback.AnimationStateMachine.State.IDLE, stateMachine.getState());
+    ArgumentCaptor<WindowInsets> captor = ArgumentCaptor.forClass(WindowInsets.class);
+    verify(view, org.mockito.Mockito.atLeastOnce()).onApplyWindowInsets(captor.capture());
+    assertEquals(
+        FINAL_IME_BOTTOM_INSET, captor.getValue().getInsets(WindowInsets.Type.ime()).bottom);
+  }
+
+  /**
+   * Tests backgrounding and resuming the app with a cancelled hide animation (issue #191156).
+   *
+   * <p>Verifies that when an app resumes from the background after the keyboard is dismissed, and
+   * Android cancels an internal hide animation (PHASE_CLIENT_ANIMATION_CANCEL), stale keyboard
+   * insets from the previous open state are not restored.
+   */
+  @Test
+  public void backgroundResume_cancelledHideAnimation_doesNotRestoreStaleKeyboardInsets() {
+    View view = mock(View.class);
+    ImeSyncDeferringInsetsCallback callback = new ImeSyncDeferringInsetsCallback(view);
+
+    WindowInsetsAnimation openAnimation = mock(WindowInsetsAnimation.class);
+    when(openAnimation.getTypeMask()).thenReturn(WindowInsets.Type.ime());
+
+    // 1. Keyboard opens to full height (e.g. 100px).
+    callback.getAnimationCallback().onPrepare(openAnimation);
+    WindowInsets openInsets =
+        new WindowInsets.Builder()
+            .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, FINAL_IME_BOTTOM_INSET))
+            .build();
+    callback.getInsetsListener().onApplyWindowInsets(view, openInsets);
+    when(openAnimation.getInterpolatedFraction()).thenReturn(1.0f);
+    callback
+        .getAnimationCallback()
+        .onProgress(openInsets, Collections.singletonList(openAnimation));
+    callback.getAnimationCallback().onEnd(openAnimation);
+
+    // Verify keyboard settled at 100px.
+    ArgumentCaptor<WindowInsets> openCaptor = ArgumentCaptor.forClass(WindowInsets.class);
+    verify(view).dispatchApplyWindowInsets(openCaptor.capture());
+    assertEquals(
+        FINAL_IME_BOTTOM_INSET, openCaptor.getValue().getInsets(WindowInsets.Type.ime()).bottom);
+
+    // 2. App is backgrounded and resumes: OS delivers non-animated insets with ime = 0.
+    WindowInsets resumeZeroInsets =
+        new WindowInsets.Builder()
+            .setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 0))
+            .build();
+    callback.getInsetsListener().onApplyWindowInsets(view, resumeZeroInsets);
+
+    ArgumentCaptor<WindowInsets> resumeCaptor = ArgumentCaptor.forClass(WindowInsets.class);
+    verify(view, org.mockito.Mockito.atLeastOnce()).onApplyWindowInsets(resumeCaptor.capture());
+    assertEquals(0, resumeCaptor.getValue().getInsets(WindowInsets.Type.ime()).bottom);
+
+    // 3. Android InsetsController initiates hide(ime()) animation, which gets cancelled
+    // (onPrepare called, followed immediately by onEnd without onApplyWindowInsets or onProgress).
+    WindowInsetsAnimation cancelledHideAnimation = mock(WindowInsetsAnimation.class);
+    when(cancelledHideAnimation.getTypeMask()).thenReturn(WindowInsets.Type.ime());
+
+    callback.getAnimationCallback().onPrepare(cancelledHideAnimation);
+    callback.getAnimationCallback().onEnd(cancelledHideAnimation);
+
+    // 4. Assert that view.dispatchApplyWindowInsets was NOT called again with the stale 100px
+    // insets.
+    // The only dispatchApplyWindowInsets call should have been the initial open from step 1.
+    verify(view, org.mockito.Mockito.times(1))
+        .dispatchApplyWindowInsets(org.mockito.ArgumentMatchers.any(WindowInsets.class));
+  }
+}
diff --git a/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java b/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java
index fd3b63c..83fdecf 100644
--- a/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java
+++ b/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java
@@ -2695,25 +2695,26 @@
             verify(flutterRenderer, atLeast(1)).setViewportMetrics(viewportMetricsCaptor.capture());
             assertEquals(0, viewportMetricsCaptor.getValue().viewInsetBottom);
 
-            // Progress the animation and ensure that the navigation bar insets have been subtracted
-            // from the IME insets
+            // Progress the animation and ensure that the IME insets smoothly interpolate
+            // to the settled target IME insets.
+            when(animation.getInterpolatedFraction()).thenReturn(0.25f);
             builder.setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 25));
             builder.setInsets(WindowInsets.Type.navigationBars(), Insets.of(0, 0, 0, 40));
             imeSyncCallback.getAnimationCallback().onProgress(builder.build(), animationList);
 
             verify(flutterRenderer, atLeast(1)).setViewportMetrics(viewportMetricsCaptor.capture());
-            assertEquals(0, viewportMetricsCaptor.getValue().viewInsetBottom);
+            assertEquals(25, viewportMetricsCaptor.getValue().viewInsetBottom);
 
+            when(animation.getInterpolatedFraction()).thenReturn(0.50f);
             builder.setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 50));
             builder.setInsets(WindowInsets.Type.navigationBars(), Insets.of(0, 0, 0, 40));
             imeSyncCallback.getAnimationCallback().onProgress(builder.build(), animationList);
 
             verify(flutterRenderer, atLeast(1)).setViewportMetrics(viewportMetricsCaptor.capture());
-            assertEquals(10, viewportMetricsCaptor.getValue().viewInsetBottom);
+            assertEquals(50, viewportMetricsCaptor.getValue().viewInsetBottom);
 
             // End the animation and ensure that the bottom insets match the lastWindowInsets that
-            // we set
-            // during onPrepare
+            // we set during onPrepare.
             imeSyncCallback.getAnimationCallback().onEnd(animation);
 
             verify(flutterRenderer, atLeast(1)).setViewportMetrics(viewportMetricsCaptor.capture());
@@ -2792,6 +2793,7 @@
 
             // Progress the animation and ensure that the navigation bar insets have not been
             // subtracted from the IME insets
+            when(animation.getInterpolatedFraction()).thenReturn(0.25f);
             builder.setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 25));
             builder.setInsets(WindowInsets.Type.navigationBars(), Insets.of(0, 0, 0, 40));
             imeSyncCallback.getAnimationCallback().onProgress(builder.build(), animationList);
@@ -2799,6 +2801,7 @@
             verify(flutterRenderer, atLeast(1)).setViewportMetrics(viewportMetricsCaptor.capture());
             assertEquals(25, viewportMetricsCaptor.getValue().viewInsetBottom);
 
+            when(animation.getInterpolatedFraction()).thenReturn(0.50f);
             builder.setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 50));
             builder.setInsets(WindowInsets.Type.navigationBars(), Insets.of(0, 0, 0, 40));
             imeSyncCallback.getAnimationCallback().onProgress(builder.build(), animationList);
@@ -2889,6 +2892,7 @@
 
             // Progress the animation and ensure that the navigation bar insets have not been
             // subtracted from the IME insets
+            when(animation.getInterpolatedFraction()).thenReturn(0.25f);
             builder.setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 25));
             builder.setInsets(WindowInsets.Type.navigationBars(), Insets.of(0, 0, 0, 40));
             imeSyncCallback.getAnimationCallback().onProgress(builder.build(), animationList);
@@ -2896,6 +2900,7 @@
             verify(flutterRenderer, atLeast(1)).setViewportMetrics(viewportMetricsCaptor.capture());
             assertEquals(25, viewportMetricsCaptor.getValue().viewInsetBottom);
 
+            when(animation.getInterpolatedFraction()).thenReturn(0.50f);
             builder.setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 50));
             builder.setInsets(WindowInsets.Type.navigationBars(), Insets.of(0, 0, 0, 40));
             imeSyncCallback.getAnimationCallback().onProgress(builder.build(), animationList);
diff --git a/packages/flutter/test/widgets/ime_insets_animation_layout_test.dart b/packages/flutter/test/widgets/ime_insets_animation_layout_test.dart
new file mode 100644
index 0000000..e13ae34
--- /dev/null
+++ b/packages/flutter/test/widgets/ime_insets_animation_layout_test.dart
@@ -0,0 +1,158 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/widgets.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+/// Test suite validating visual layout continuity and absence of terminal
+/// insets jumps during soft keyboard animation in edgeToEdge mode.
+///
+/// What it tests:
+/// Compares layout and widget positions at the terminal progress frame (fraction = 1.0)
+/// versus the settled state (onEnd) for:
+/// 1. The baseline buggy engine behavior (where onProgress subtracted navigation bar height,
+///    causing a 24dp jump upon onEnd dispatch).
+/// 2. The fixed engine behavior (where target-inset interpolation ensures 0dp terminal jump).
+///
+/// Why it was added:
+/// Regression test for flutter/flutter#190974 ("viewInsets.bottom jumps by navigation bar
+/// height at the end of IME open animation in edgeToEdge mode on Android API 30-34").
+void main() {
+  testWidgets('IME insets animation comparison: before vs after fix visual continuity', (
+    WidgetTester tester,
+  ) async {
+    // Exact reproduction UI from issue #190974:
+    // A view with bottom padding driven by MediaQuery.viewInsetsOf(context).bottom.
+    // In edgeToEdge mode on API 34, viewPadding.bottom is 24dp (gesture bar).
+    Widget buildTestApp(double bottomInset) {
+      return Directionality(
+        textDirection: TextDirection.ltr,
+        child: MediaQuery(
+          data: MediaQueryData(
+            size: const Size(400, 800),
+            padding: const EdgeInsets.only(bottom: 24),
+            viewPadding: const EdgeInsets.only(bottom: 24),
+            viewInsets: EdgeInsets.only(bottom: bottomInset),
+          ),
+          child: ColoredBox(
+            color: const Color(0xFFECEFF1),
+            child: Builder(
+              builder: (BuildContext context) {
+                final double insetsBottom = MediaQuery.viewInsetsOf(context).bottom;
+                final double paddingBottom = MediaQuery.viewPaddingOf(context).bottom;
+                return Padding(
+                  padding: EdgeInsets.only(bottom: insetsBottom),
+                  child: Column(
+                    children: <Widget>[
+                      const SizedBox(height: 40),
+                      Text(
+                        'IME Insets: ${insetsBottom.toStringAsFixed(1)} dp | Padding: ${paddingBottom.toStringAsFixed(1)} dp',
+                        key: const ValueKey<String>('metrics_label'),
+                        textDirection: TextDirection.ltr,
+                        style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
+                      ),
+                      const Spacer(),
+                      // Distinct indicator strip at the bottom of the content area
+                      const ColoredBox(
+                        key: ValueKey<String>('indicator_bar'),
+                        color: Color(0xFFFF0000),
+                        child: SizedBox(height: 8, width: double.infinity),
+                      ),
+                      const ColoredBox(
+                        key: ValueKey<String>('input_box'),
+                        color: Color(0xFFCFD8DC),
+                        child: SizedBox(
+                          height: 56,
+                          width: double.infinity,
+                          child: Center(
+                            child: Text(
+                              'Input Field (pinned above keyboard)',
+                              textDirection: TextDirection.ltr,
+                            ),
+                          ),
+                        ),
+                      ),
+                    ],
+                  ),
+                );
+              },
+            ),
+          ),
+        ),
+      );
+    }
+
+    const targetSettledIme = 336.0; // Settled keyboard height on API 34
+    const navBarHeight = 24.0; // Navigation bar height on API 34
+
+    // -------------------------------------------------------------------------
+    // 1. BEFORE FIX: Simulating the bug from #190974
+    // -------------------------------------------------------------------------
+    // During animation, excludedInsets subtracted navBarHeight (24dp).
+    // Terminal frame of onProgress reported 336 - 24 = 312 dp.
+    // Immediately onEnd dispatched 336 dp -> 24 dp jump.
+    const double beforeTerminalProgress = targetSettledIme - navBarHeight; // 312.0
+    const beforeSettled = targetSettledIme; // 336.0
+
+    // Render Before Terminal Frame (312dp)
+    await tester.pumpWidget(buildTestApp(beforeTerminalProgress));
+    await tester.pumpAndSettle();
+    final Rect beforeTerminalRect = tester.getRect(
+      find.byKey(const ValueKey<String>('indicator_bar')),
+    );
+
+    // Render Before Settled Frame (336dp)
+    await tester.pumpWidget(buildTestApp(beforeSettled));
+    await tester.pumpAndSettle();
+    final Rect beforeSettledRect = tester.getRect(
+      find.byKey(const ValueKey<String>('indicator_bar')),
+    );
+
+    // The jump before the fix:
+    final double beforeJump = beforeTerminalRect.bottom - beforeSettledRect.bottom;
+    expect(
+      beforeJump,
+      equals(navBarHeight),
+      reason: 'Before fix, indicator jumps by navBarHeight (24dp) at onEnd',
+    );
+
+    // -------------------------------------------------------------------------
+    // 2. AFTER FIX (fix-ime-insets-animation-jump branch)
+    // -------------------------------------------------------------------------
+    // In the fixed branch, animated insets are interpolated to targetSettledIme (336dp).
+    // Terminal progress frame (fraction = 1.0) reports 336.0 dp.
+    // onEnd frame dispatches 336.0 dp.
+    const afterTerminalProgress = targetSettledIme; // 336.0
+    const afterSettled = targetSettledIme; // 336.0
+
+    // Render After Terminal Frame (336dp)
+    await tester.pumpWidget(buildTestApp(afterTerminalProgress));
+    await tester.pumpAndSettle();
+    final Rect afterTerminalRect = tester.getRect(
+      find.byKey(const ValueKey<String>('indicator_bar')),
+    );
+
+    // Render After Settled Frame (336dp)
+    await tester.pumpWidget(buildTestApp(afterSettled));
+    await tester.pumpAndSettle();
+    final Rect afterSettledRect = tester.getRect(
+      find.byKey(const ValueKey<String>('indicator_bar')),
+    );
+
+    // The jump after the fix:
+    final double afterJump = afterTerminalRect.bottom - afterSettledRect.bottom;
+    expect(
+      afterJump,
+      equals(0.0),
+      reason: 'After fix, indicator position is perfectly continuous with 0 jump',
+    );
+
+    // -------------------------------------------------------------------------
+    // 3. Compare visual position matching
+    // -------------------------------------------------------------------------
+    expect(afterTerminalRect, equals(afterSettledRect));
+    expect(afterSettledRect, equals(beforeSettledRect));
+    expect(beforeTerminalRect, isNot(equals(afterTerminalRect)));
+  });
+}