From a537bc64faf541fc8a9e79871e76b0dcb7114d32 Mon Sep 17 00:00:00 2001 From: "Lincoln Baxter, III" Date: Wed, 19 Aug 2026 10:45:01 -0400 Subject: [PATCH 1/7] feat(android): add LAST_KNOWN eventMode config option This adds an eventMode configuration option for Android to smooth out intermediate/jittering keyboard heights that are incorrectly emitted during WindowInsetsAnimation. Setting this to LAST_KNOWN ensures that listeners only receive the true/final keyboard height, resolving layout jump issues on Android 15/edge-to-edge configurations. --- .../plugins/keyboard/Keyboard.java | 103 +++++++++++++++++- .../plugins/keyboard/KeyboardPlugin.java | 2 + src/definitions.ts | 12 ++ 3 files changed, 112 insertions(+), 5 deletions(-) diff --git a/android/src/main/java/com/capacitorjs/plugins/keyboard/Keyboard.java b/android/src/main/java/com/capacitorjs/plugins/keyboard/Keyboard.java index a31085c..7bd5c70 100644 --- a/android/src/main/java/com/capacitorjs/plugins/keyboard/Keyboard.java +++ b/android/src/main/java/com/capacitorjs/plugins/keyboard/Keyboard.java @@ -31,6 +31,27 @@ interface KeyboardEventListener { private int usableHeightPrevious; private FrameLayout.LayoutParams frameLayoutParams; private View mChildOfContent; + private int lastImeHeight = 0; + + public enum EventMode { + DEFAULT, + LAST_KNOWN + } + + private boolean isAnimating = false; + private boolean justEndedAnimation = false; + private int knownKeyboardHeight = 0; + private EventMode eventMode = EventMode.DEFAULT; + + public void setEventMode(String modeStr) { + if (modeStr != null) { + try { + this.eventMode = EventMode.valueOf(modeStr.toUpperCase()); + } catch (IllegalArgumentException e) { + this.eventMode = EventMode.DEFAULT; + } + } + } public void setKeyboardEventListener(@Nullable KeyboardEventListener keyboardEventListener) { this.keyboardEventListener = keyboardEventListener; @@ -63,25 +84,82 @@ public Keyboard(AppCompatActivity activity, boolean resizeOnFullScreen) { if (rootInsets == null) return insets; boolean showingKeyboard = rootInsets.isVisible(WindowInsetsCompat.Type.ime()); + int imeHeight = rootInsets.getInsets(WindowInsetsCompat.Type.ime()).bottom; + DisplayMetrics dm = activity.getResources().getDisplayMetrics(); + final float density = dm.density; + + if (showingKeyboard) { + int currentImeHeight = Math.round(imeHeight / density); + if (!isAnimating) { + int emitHeight = currentImeHeight; + if (eventMode == EventMode.LAST_KNOWN) { + if (knownKeyboardHeight == 0) { + knownKeyboardHeight = currentImeHeight; + } else if (justEndedAnimation) { + knownKeyboardHeight = currentImeHeight; + } else if (currentImeHeight > knownKeyboardHeight) { + emitHeight = knownKeyboardHeight; + } else if (currentImeHeight < knownKeyboardHeight) { + knownKeyboardHeight = currentImeHeight; + } + } else { + knownKeyboardHeight = currentImeHeight; + } + + if (emitHeight != lastImeHeight && keyboardEventListener != null) { + lastImeHeight = emitHeight; + keyboardEventListener.onKeyboardEvent(EVENT_KB_WILL_SHOW, emitHeight); + keyboardEventListener.onKeyboardEvent(EVENT_KB_DID_SHOW, emitHeight); + } + justEndedAnimation = false; + } else if (eventMode == EventMode.LAST_KNOWN) { + if (knownKeyboardHeight == 0) { + knownKeyboardHeight = currentImeHeight; + } + } + } else { + lastImeHeight = 0; + justEndedAnimation = false; + } - if (resizeOnFullScreen) { - possiblyResizeChildOfContent(showingKeyboard); + logDebug("onApplyWindowInsets - showingKeyboard=" + showingKeyboard, insets, imeHeight); + + if (showingKeyboard && resizeOnFullScreen) { + possiblyResizeChildOfContent(true); + } else if (!showingKeyboard && resizeOnFullScreen) { + possiblyResizeChildOfContent(false); } - v.onApplyWindowInsets(insets.toWindowInsets()); + WindowInsetsCompat insetsToApply = insets; + if (!resizeOnFullScreen) { + insetsToApply = new WindowInsetsCompat.Builder(insets) + .setInsets(WindowInsetsCompat.Type.ime(), androidx.core.graphics.Insets.NONE) + .build(); + } - return insets; + return ViewCompat.onApplyWindowInsets(v, insetsToApply); }); ViewCompat.setWindowInsetsAnimationCallback( rootView, new WindowInsetsAnimationCompat.Callback(WindowInsetsAnimationCompat.Callback.DISPATCH_MODE_STOP) { + @Override + public void onPrepare(@NonNull WindowInsetsAnimationCompat animation) { + isAnimating = true; + super.onPrepare(animation); + } + @NonNull @Override public WindowInsetsCompat onProgress( @NonNull WindowInsetsCompat insets, @NonNull List runningAnimations ) { + if (!resizeOnFullScreen) { + return new WindowInsetsCompat.Builder(insets) + .setInsets(WindowInsetsCompat.Type.ime(), androidx.core.graphics.Insets.NONE) + .build(); + } return insets; } @@ -91,6 +169,8 @@ public WindowInsetsAnimationCompat.BoundsCompat onStart( @NonNull WindowInsetsAnimationCompat animation, @NonNull WindowInsetsAnimationCompat.BoundsCompat bounds ) { + isAnimating = true; + justEndedAnimation = false; WindowInsetsCompat insets = ViewCompat.getRootWindowInsets(rootView); if (insets == null) return super.onStart(animation, bounds); boolean showingKeyboard = insets.isVisible(WindowInsetsCompat.Type.ime()); @@ -103,10 +183,23 @@ public WindowInsetsAnimationCompat.BoundsCompat onStart( } if (showingKeyboard) { - keyboardEventListener.onKeyboardEvent(EVENT_KB_WILL_SHOW, Math.round(imeHeight / density)); + int currentImeHeight = Math.round(imeHeight / density); + int emitHeight = currentImeHeight; + if (eventMode == EventMode.LAST_KNOWN && knownKeyboardHeight > 0 && currentImeHeight > knownKeyboardHeight) { + emitHeight = knownKeyboardHeight; + } + + if (emitHeight != lastImeHeight) { + lastImeHeight = emitHeight; + keyboardEventListener.onKeyboardEvent(EVENT_KB_WILL_SHOW, lastImeHeight); + } } else { + lastImeHeight = 0; keyboardEventListener.onKeyboardEvent(EVENT_KB_WILL_HIDE, 0); } + + logDebug("onStart - showingKeyboard=" + showingKeyboard, insets, imeHeight); + return super.onStart(animation, bounds); } diff --git a/android/src/main/java/com/capacitorjs/plugins/keyboard/KeyboardPlugin.java b/android/src/main/java/com/capacitorjs/plugins/keyboard/KeyboardPlugin.java index 40fe0b7..f55c4b3 100644 --- a/android/src/main/java/com/capacitorjs/plugins/keyboard/KeyboardPlugin.java +++ b/android/src/main/java/com/capacitorjs/plugins/keyboard/KeyboardPlugin.java @@ -17,7 +17,9 @@ public class KeyboardPlugin extends Plugin { public void load() { execute(() -> { boolean resizeOnFullScreen = getConfig().getBoolean("resizeOnFullScreen", false); + String eventMode = getConfig().getString("eventMode", "DEFAULT"); implementation = new Keyboard(getBridge(), resizeOnFullScreen); + implementation.setEventMode(eventMode); implementation.setKeyboardEventListener(this::onKeyboardEvent); }); diff --git a/src/definitions.ts b/src/definitions.ts index 9605871..b415470 100644 --- a/src/definitions.ts +++ b/src/definitions.ts @@ -42,6 +42,16 @@ declare module '@capacitor/cli' { */ resizeOnFullScreen?: boolean; + /** + * The event mode determines how the plugin emits lifecycle events during keyboard animations. + * + * Only available for Android + * + * @since 8.0.5 + * @example "LAST_KNOWN" + */ + eventMode?: KeyboardEventMode; + /** * Controls how the keyboard backdrop color (the area visible behind the * keyboard) is set every time the keyboard is about to show. @@ -64,6 +74,8 @@ declare module '@capacitor/cli' { } } +export type KeyboardEventMode = 'DEFAULT' | 'LAST_KNOWN'; + export interface KeyboardInfo { /** * Height of the keyboard. From 2b972d9e3f77265dfcb2cd07f9c5200c91207271 Mon Sep 17 00:00:00 2001 From: "Lincoln Baxter, III" Date: Mon, 24 Aug 2026 14:09:19 -0400 Subject: [PATCH 2/7] fix(android): mark animation ended to prevent spurious tall frames from corrupting keyboard ceiling --- .../plugins/keyboard/Keyboard.java | 38 +++++++++++++++++-- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/android/src/main/java/com/capacitorjs/plugins/keyboard/Keyboard.java b/android/src/main/java/com/capacitorjs/plugins/keyboard/Keyboard.java index 7bd5c70..c29f1c7 100644 --- a/android/src/main/java/com/capacitorjs/plugins/keyboard/Keyboard.java +++ b/android/src/main/java/com/capacitorjs/plugins/keyboard/Keyboard.java @@ -122,7 +122,7 @@ public Keyboard(AppCompatActivity activity, boolean resizeOnFullScreen) { justEndedAnimation = false; } - logDebug("onApplyWindowInsets - showingKeyboard=" + showingKeyboard, insets, imeHeight); + if (showingKeyboard && resizeOnFullScreen) { possiblyResizeChildOfContent(true); @@ -198,7 +198,7 @@ public WindowInsetsAnimationCompat.BoundsCompat onStart( keyboardEventListener.onKeyboardEvent(EVENT_KB_WILL_HIDE, 0); } - logDebug("onStart - showingKeyboard=" + showingKeyboard, insets, imeHeight); + return super.onStart(animation, bounds); } @@ -206,6 +206,8 @@ public WindowInsetsAnimationCompat.BoundsCompat onStart( @Override public void onEnd(@NonNull WindowInsetsAnimationCompat animation) { super.onEnd(animation); + isAnimating = false; + WindowInsetsCompat insets = ViewCompat.getRootWindowInsets(rootView); if (insets == null) return; boolean showingKeyboard = insets.isVisible(WindowInsetsCompat.Type.ime()); @@ -214,10 +216,38 @@ public void onEnd(@NonNull WindowInsetsAnimationCompat animation) { final float density = dm.density; if (showingKeyboard) { - keyboardEventListener.onKeyboardEvent(EVENT_KB_DID_SHOW, Math.round(imeHeight / density)); + justEndedAnimation = true; + + int currentImeHeight = Math.round(imeHeight / density); + int emitHeight = currentImeHeight; + if (eventMode == EventMode.LAST_KNOWN && knownKeyboardHeight > 0 && currentImeHeight > knownKeyboardHeight) { + emitHeight = knownKeyboardHeight; + } + + if (emitHeight != lastImeHeight) { + lastImeHeight = emitHeight; + if (keyboardEventListener != null) { + keyboardEventListener.onKeyboardEvent(EVENT_KB_DID_SHOW, lastImeHeight); + } + } + + // Update the known keyboard height to the final settled height after animation. + // We ONLY allow it to shrink here to prevent spurious tall frames (like during rapid aborts) from corrupting the ceiling. + // If the keyboard genuinely grew, the subsequent onApplyWindowInsets layout pass will catch it via justEndedAnimation=true. + if (eventMode == EventMode.LAST_KNOWN) { + if (knownKeyboardHeight == 0 || currentImeHeight <= knownKeyboardHeight) { + knownKeyboardHeight = currentImeHeight; + } + } } else { - keyboardEventListener.onKeyboardEvent(EVENT_KB_DID_HIDE, 0); + justEndedAnimation = false; + lastImeHeight = 0; + if (keyboardEventListener != null) { + keyboardEventListener.onKeyboardEvent(EVENT_KB_DID_HIDE, 0); + } } + + } } ); From 8dedee06d59188ea764a087683df159cc7b7620e Mon Sep 17 00:00:00 2001 From: "Lincoln Baxter, III" Date: Wed, 26 Aug 2026 15:01:18 -0400 Subject: [PATCH 3/7] fix(android): implement screen-width based height ceiling to fix rotation glitches * Extracts Android window inset math into a pure Java KeyboardHeightFilter state controller. * Implements a Map to track the maximum known keyboard height independently for different screen widths (e.g., Portrait vs Landscape). * Fixes rapid-abort and SwiftKey double-fire glitches by capping spurious animation heights to the known ceiling. * Fixes rotation transition bugs where the OS fires showing=true with a 0px height. * Adds a comprehensive suite of pure JUnit tests to guarantee 100% coverage of all lifecycle and glitch scenarios. * Retains android.util.Log traces in Keyboard.java to assist with production debugging. --- .../plugins/keyboard/Keyboard.java | 119 +++------ .../keyboard/KeyboardHeightFilter.java | 103 ++++++++ .../keyboard/KeyboardHeightFilterTest.java | 246 ++++++++++++++++++ 3 files changed, 381 insertions(+), 87 deletions(-) create mode 100644 android/src/main/java/com/capacitorjs/plugins/keyboard/KeyboardHeightFilter.java create mode 100644 android/src/test/java/com/capacitorjs/plugins/keyboard/KeyboardHeightFilterTest.java diff --git a/android/src/main/java/com/capacitorjs/plugins/keyboard/Keyboard.java b/android/src/main/java/com/capacitorjs/plugins/keyboard/Keyboard.java index c29f1c7..d728b6f 100644 --- a/android/src/main/java/com/capacitorjs/plugins/keyboard/Keyboard.java +++ b/android/src/main/java/com/capacitorjs/plugins/keyboard/Keyboard.java @@ -1,6 +1,7 @@ package com.capacitorjs.plugins.keyboard; import android.content.Context; +import android.util.Log; import android.graphics.Rect; import android.os.Build; import android.util.DisplayMetrics; @@ -31,24 +32,20 @@ interface KeyboardEventListener { private int usableHeightPrevious; private FrameLayout.LayoutParams frameLayoutParams; private View mChildOfContent; - private int lastImeHeight = 0; public enum EventMode { DEFAULT, LAST_KNOWN } - private boolean isAnimating = false; - private boolean justEndedAnimation = false; - private int knownKeyboardHeight = 0; - private EventMode eventMode = EventMode.DEFAULT; + private final KeyboardHeightFilter filter = new KeyboardHeightFilter(); public void setEventMode(String modeStr) { if (modeStr != null) { try { - this.eventMode = EventMode.valueOf(modeStr.toUpperCase()); + filter.setEventMode(EventMode.valueOf(modeStr.toUpperCase())); } catch (IllegalArgumentException e) { - this.eventMode = EventMode.DEFAULT; + filter.setEventMode(EventMode.DEFAULT); } } } @@ -88,42 +85,20 @@ public Keyboard(AppCompatActivity activity, boolean resizeOnFullScreen) { DisplayMetrics dm = activity.getResources().getDisplayMetrics(); final float density = dm.density; - if (showingKeyboard) { - int currentImeHeight = Math.round(imeHeight / density); - if (!isAnimating) { - int emitHeight = currentImeHeight; - if (eventMode == EventMode.LAST_KNOWN) { - if (knownKeyboardHeight == 0) { - knownKeyboardHeight = currentImeHeight; - } else if (justEndedAnimation) { - knownKeyboardHeight = currentImeHeight; - } else if (currentImeHeight > knownKeyboardHeight) { - emitHeight = knownKeyboardHeight; - } else if (currentImeHeight < knownKeyboardHeight) { - knownKeyboardHeight = currentImeHeight; - } - } else { - knownKeyboardHeight = currentImeHeight; - } - - if (emitHeight != lastImeHeight && keyboardEventListener != null) { - lastImeHeight = emitHeight; - keyboardEventListener.onKeyboardEvent(EVENT_KB_WILL_SHOW, emitHeight); - keyboardEventListener.onKeyboardEvent(EVENT_KB_DID_SHOW, emitHeight); - } - justEndedAnimation = false; - } else if (eventMode == EventMode.LAST_KNOWN) { - if (knownKeyboardHeight == 0) { - knownKeyboardHeight = currentImeHeight; - } + int currentImeHeight = Math.round(imeHeight / density); + KeyboardHeightFilter.FilterResult result = filter.filterOnApplyWindowInsets(showingKeyboard, currentImeHeight, dm.widthPixels); + Log.i("Capacitor/Keyboard", "onApplyWindowInsets: showing=" + showingKeyboard + " rawHeight=" + currentImeHeight + " emit=" + result.emitHeight + " shouldEmit=" + result.shouldEmit); + + if (result.shouldEmit && keyboardEventListener != null) { + if (showingKeyboard) { + keyboardEventListener.onKeyboardEvent(EVENT_KB_WILL_SHOW, result.emitHeight); + keyboardEventListener.onKeyboardEvent(EVENT_KB_DID_SHOW, result.emitHeight); + } else { + keyboardEventListener.onKeyboardEvent(EVENT_KB_WILL_HIDE, 0); + keyboardEventListener.onKeyboardEvent(EVENT_KB_DID_HIDE, 0); } - } else { - lastImeHeight = 0; - justEndedAnimation = false; } - - if (showingKeyboard && resizeOnFullScreen) { possiblyResizeChildOfContent(true); } else if (!showingKeyboard && resizeOnFullScreen) { @@ -145,7 +120,8 @@ public Keyboard(AppCompatActivity activity, boolean resizeOnFullScreen) { new WindowInsetsAnimationCompat.Callback(WindowInsetsAnimationCompat.Callback.DISPATCH_MODE_STOP) { @Override public void onPrepare(@NonNull WindowInsetsAnimationCompat animation) { - isAnimating = true; + Log.i("Capacitor/Keyboard", "onPrepareAnimation"); + filter.onPrepareAnimation(); super.onPrepare(animation); } @@ -169,8 +145,6 @@ public WindowInsetsAnimationCompat.BoundsCompat onStart( @NonNull WindowInsetsAnimationCompat animation, @NonNull WindowInsetsAnimationCompat.BoundsCompat bounds ) { - isAnimating = true; - justEndedAnimation = false; WindowInsetsCompat insets = ViewCompat.getRootWindowInsets(rootView); if (insets == null) return super.onStart(animation, bounds); boolean showingKeyboard = insets.isVisible(WindowInsetsCompat.Type.ime()); @@ -182,31 +156,24 @@ public WindowInsetsAnimationCompat.BoundsCompat onStart( possiblyResizeChildOfContent(showingKeyboard); } - if (showingKeyboard) { - int currentImeHeight = Math.round(imeHeight / density); - int emitHeight = currentImeHeight; - if (eventMode == EventMode.LAST_KNOWN && knownKeyboardHeight > 0 && currentImeHeight > knownKeyboardHeight) { - emitHeight = knownKeyboardHeight; - } + int currentImeHeight = Math.round(imeHeight / density); + KeyboardHeightFilter.FilterResult result = filter.filterOnStart(showingKeyboard, currentImeHeight, dm.widthPixels); + Log.i("Capacitor/Keyboard", "onStart: showing=" + showingKeyboard + " rawHeight=" + currentImeHeight + " emit=" + result.emitHeight + " shouldEmit=" + result.shouldEmit); - if (emitHeight != lastImeHeight) { - lastImeHeight = emitHeight; - keyboardEventListener.onKeyboardEvent(EVENT_KB_WILL_SHOW, lastImeHeight); + if (result.shouldEmit && keyboardEventListener != null) { + if (showingKeyboard) { + keyboardEventListener.onKeyboardEvent(EVENT_KB_WILL_SHOW, result.emitHeight); + } else { + keyboardEventListener.onKeyboardEvent(EVENT_KB_WILL_HIDE, 0); } - } else { - lastImeHeight = 0; - keyboardEventListener.onKeyboardEvent(EVENT_KB_WILL_HIDE, 0); } - - return super.onStart(animation, bounds); } @Override public void onEnd(@NonNull WindowInsetsAnimationCompat animation) { super.onEnd(animation); - isAnimating = false; WindowInsetsCompat insets = ViewCompat.getRootWindowInsets(rootView); if (insets == null) return; @@ -215,39 +182,17 @@ public void onEnd(@NonNull WindowInsetsAnimationCompat animation) { DisplayMetrics dm = activity.getResources().getDisplayMetrics(); final float density = dm.density; - if (showingKeyboard) { - justEndedAnimation = true; - - int currentImeHeight = Math.round(imeHeight / density); - int emitHeight = currentImeHeight; - if (eventMode == EventMode.LAST_KNOWN && knownKeyboardHeight > 0 && currentImeHeight > knownKeyboardHeight) { - emitHeight = knownKeyboardHeight; - } - - if (emitHeight != lastImeHeight) { - lastImeHeight = emitHeight; - if (keyboardEventListener != null) { - keyboardEventListener.onKeyboardEvent(EVENT_KB_DID_SHOW, lastImeHeight); - } - } + int currentImeHeight = Math.round(imeHeight / density); + KeyboardHeightFilter.FilterResult result = filter.filterOnEnd(showingKeyboard, currentImeHeight, dm.widthPixels); + Log.i("Capacitor/Keyboard", "onEnd: showing=" + showingKeyboard + " rawHeight=" + currentImeHeight + " emit=" + result.emitHeight + " shouldEmit=" + result.shouldEmit); - // Update the known keyboard height to the final settled height after animation. - // We ONLY allow it to shrink here to prevent spurious tall frames (like during rapid aborts) from corrupting the ceiling. - // If the keyboard genuinely grew, the subsequent onApplyWindowInsets layout pass will catch it via justEndedAnimation=true. - if (eventMode == EventMode.LAST_KNOWN) { - if (knownKeyboardHeight == 0 || currentImeHeight <= knownKeyboardHeight) { - knownKeyboardHeight = currentImeHeight; - } - } - } else { - justEndedAnimation = false; - lastImeHeight = 0; - if (keyboardEventListener != null) { + if (result.shouldEmit && keyboardEventListener != null) { + if (showingKeyboard) { + keyboardEventListener.onKeyboardEvent(EVENT_KB_DID_SHOW, result.emitHeight); + } else { keyboardEventListener.onKeyboardEvent(EVENT_KB_DID_HIDE, 0); } } - - } } ); diff --git a/android/src/main/java/com/capacitorjs/plugins/keyboard/KeyboardHeightFilter.java b/android/src/main/java/com/capacitorjs/plugins/keyboard/KeyboardHeightFilter.java new file mode 100644 index 0000000..a60431c --- /dev/null +++ b/android/src/main/java/com/capacitorjs/plugins/keyboard/KeyboardHeightFilter.java @@ -0,0 +1,103 @@ +package com.capacitorjs.plugins.keyboard; + +import java.util.HashMap; +import java.util.Map; + +public class KeyboardHeightFilter { + private Keyboard.EventMode eventMode = Keyboard.EventMode.DEFAULT; + private final Map knownHeights = new HashMap<>(); + private int lastImeHeight = 0; + private boolean isAnimating = false; + + public void setEventMode(Keyboard.EventMode eventMode) { + this.eventMode = eventMode; + } + + public static class FilterResult { + public final int emitHeight; + public final boolean shouldEmit; + + public FilterResult(int emitHeight, boolean shouldEmit) { + this.emitHeight = emitHeight; + this.shouldEmit = shouldEmit; + } + } + + public void onPrepareAnimation() { + this.isAnimating = true; + } + + public FilterResult filterOnApplyWindowInsets(boolean showingKeyboard, int currentImeHeight, int screenWidth) { + if (isAnimating) { + return new FilterResult(0, false); + } + + if (!showingKeyboard) { + lastImeHeight = 0; + return new FilterResult(0, false); + } + + if (currentImeHeight > 0) { + knownHeights.put(screenWidth, currentImeHeight); + } + int emitHeight = currentImeHeight; + + boolean shouldEmit = emitHeight != lastImeHeight; + if (shouldEmit) { + lastImeHeight = emitHeight; + } + return new FilterResult(emitHeight, shouldEmit); + } + + public FilterResult filterOnStart(boolean showingKeyboard, int currentImeHeight, int screenWidth) { + this.isAnimating = true; + + if (!showingKeyboard) { + lastImeHeight = 0; + return new FilterResult(0, true); + } + + int emitHeight = currentImeHeight; + Integer knownHeight = knownHeights.get(screenWidth); + + if (eventMode == Keyboard.EventMode.LAST_KNOWN && knownHeight != null && knownHeight > 0 && currentImeHeight > knownHeight) { + emitHeight = knownHeight; + } + + boolean shouldEmit = emitHeight != lastImeHeight; + if (shouldEmit) { + lastImeHeight = emitHeight; + } + return new FilterResult(emitHeight, shouldEmit); + } + + public FilterResult filterOnEnd(boolean showingKeyboard, int currentImeHeight, int screenWidth) { + this.isAnimating = false; + + if (!showingKeyboard) { + lastImeHeight = 0; + return new FilterResult(0, true); + } + + int emitHeight = currentImeHeight; + Integer knownHeight = knownHeights.get(screenWidth); + + if (eventMode == Keyboard.EventMode.LAST_KNOWN && knownHeight != null && knownHeight > 0 && currentImeHeight > knownHeight) { + emitHeight = knownHeight; + } + + lastImeHeight = emitHeight; + + // Lock in the ceiling height after the animation finishes. + if (eventMode == Keyboard.EventMode.LAST_KNOWN) { + if (emitHeight > 0) { + if (knownHeight == null || emitHeight <= knownHeight) { + knownHeights.put(screenWidth, emitHeight); + } + } + } + + // The DID_SHOW event must unconditionally fire when the animation finishes + return new FilterResult(emitHeight, true); + } +} diff --git a/android/src/test/java/com/capacitorjs/plugins/keyboard/KeyboardHeightFilterTest.java b/android/src/test/java/com/capacitorjs/plugins/keyboard/KeyboardHeightFilterTest.java new file mode 100644 index 0000000..a2bc021 --- /dev/null +++ b/android/src/test/java/com/capacitorjs/plugins/keyboard/KeyboardHeightFilterTest.java @@ -0,0 +1,246 @@ +package com.capacitorjs.plugins.keyboard; + +import org.junit.Test; +import static org.junit.Assert.*; + +public class KeyboardHeightFilterTest { + + private static final int PORTRAIT_WIDTH = 1080; + private static final int LANDSCAPE_WIDTH = 1920; + + @Test + public void testNormalOpen() { + KeyboardHeightFilter filter = new KeyboardHeightFilter(); + filter.setEventMode(Keyboard.EventMode.LAST_KNOWN); + + filter.onPrepareAnimation(); + + KeyboardHeightFilter.FilterResult startResult = filter.filterOnStart(true, 941, PORTRAIT_WIDTH); + assertTrue(startResult.shouldEmit); + assertEquals(941, startResult.emitHeight); + + KeyboardHeightFilter.FilterResult endResult = filter.filterOnEnd(true, 941, PORTRAIT_WIDTH); + assertTrue(endResult.shouldEmit); // DID_SHOW must fire unconditionally on animation end + assertEquals(941, endResult.emitHeight); + + // Stabilize after animation + KeyboardHeightFilter.FilterResult applyResult = filter.filterOnApplyWindowInsets(true, 941, PORTRAIT_WIDTH); + assertFalse(applyResult.shouldEmit); + } + + @Test + public void testNormalClose() { + KeyboardHeightFilter filter = new KeyboardHeightFilter(); + filter.setEventMode(Keyboard.EventMode.LAST_KNOWN); + + // Open first to establish height + filter.filterOnApplyWindowInsets(true, 941, PORTRAIT_WIDTH); + + filter.onPrepareAnimation(); + + // Close animation starts + KeyboardHeightFilter.FilterResult startResult = filter.filterOnStart(false, 0, PORTRAIT_WIDTH); + assertTrue(startResult.shouldEmit); + assertEquals(0, startResult.emitHeight); + + // Close animation ends + KeyboardHeightFilter.FilterResult endResult = filter.filterOnEnd(false, 0, PORTRAIT_WIDTH); + assertTrue(endResult.shouldEmit); + assertEquals(0, endResult.emitHeight); + } + + @Test + public void testRapidAbortIsCapped() { + KeyboardHeightFilter filter = new KeyboardHeightFilter(); + filter.setEventMode(Keyboard.EventMode.LAST_KNOWN); + + // Establish resting height + filter.filterOnApplyWindowInsets(true, 941, PORTRAIT_WIDTH); + + filter.onPrepareAnimation(); + + // OS gets confused and fires layout pass mid-abort BEFORE onStart + KeyboardHeightFilter.FilterResult earlyApply = filter.filterOnApplyWindowInsets(true, 1034, PORTRAIT_WIDTH); + assertFalse(earlyApply.shouldEmit); // Suppressed because isAnimating is true! + + // Rapid abort sends spurious 1034px onStart + KeyboardHeightFilter.FilterResult startResult = filter.filterOnStart(true, 1034, PORTRAIT_WIDTH); + + // The emitted height MUST be capped to the previous stable height (941) + assertFalse(startResult.shouldEmit); // 941 == 941 (previous lastImeHeight), so WILL_SHOW is suppressed to avoid redundant dispatch + assertEquals(941, startResult.emitHeight); + + // Animation ends with spurious 1034px + KeyboardHeightFilter.FilterResult endResult = filter.filterOnEnd(true, 1034, PORTRAIT_WIDTH); + assertTrue(endResult.shouldEmit); // DID_SHOW unconditionally fires because animation ended + assertEquals(941, endResult.emitHeight); // Still capped to 941 + } + + @Test + public void testSuggestionBarLegitimateGrowth() { + KeyboardHeightFilter filter = new KeyboardHeightFilter(); + filter.setEventMode(Keyboard.EventMode.LAST_KNOWN); + + // Establish resting height + filter.filterOnApplyWindowInsets(true, 941, PORTRAIT_WIDTH); + + // Suggestion bar opens (no animation, OS layout change to 1000) + // Notice we do NOT call onPrepareAnimation() here + KeyboardHeightFilter.FilterResult applyResult = filter.filterOnApplyWindowInsets(true, 1000, PORTRAIT_WIDTH); + + // It must NOT be capped! Legitimate growth must be allowed to pass through + assertTrue(applyResult.shouldEmit); + assertEquals(1000, applyResult.emitHeight); + } + + @Test + public void testKeyboardDidShowAlwaysFires() { + KeyboardHeightFilter filter = new KeyboardHeightFilter(); + filter.setEventMode(Keyboard.EventMode.LAST_KNOWN); + + filter.onPrepareAnimation(); + filter.filterOnStart(true, 941, PORTRAIT_WIDTH); // emitHeight = 941, lastImeHeight = 941 + + // onEnd fires with the EXACT same height + KeyboardHeightFilter.FilterResult endResult = filter.filterOnEnd(true, 941, PORTRAIT_WIDTH); + + // It must return shouldEmit = true to trigger DID_SHOW + assertTrue(endResult.shouldEmit); + } + + @Test + public void testApplyWindowInsetsWhenKeyboardHidden() { + KeyboardHeightFilter filter = new KeyboardHeightFilter(); + + // First open it + filter.filterOnApplyWindowInsets(true, 941, PORTRAIT_WIDTH); + + // OS hides keyboard abruptly outside of an animation + KeyboardHeightFilter.FilterResult hideResult = filter.filterOnApplyWindowInsets(false, 0, PORTRAIT_WIDTH); + + assertFalse(hideResult.shouldEmit); // should Emit is false because onApplyWindowInsets does not force emit on hide + assertEquals(0, hideResult.emitHeight); + } + + @Test + public void testDefaultModeDoesNotCapHeights() { + KeyboardHeightFilter filter = new KeyboardHeightFilter(); + filter.setEventMode(Keyboard.EventMode.DEFAULT); + + // Establish resting height + filter.filterOnApplyWindowInsets(true, 941, PORTRAIT_WIDTH); + + filter.onPrepareAnimation(); + + // Rapid abort sends spurious 1034px onStart + KeyboardHeightFilter.FilterResult startResult = filter.filterOnStart(true, 1034, PORTRAIT_WIDTH); + + // Because mode is DEFAULT, it MUST NOT cap! + assertTrue(startResult.shouldEmit); // 1034 != 941 + assertEquals(1034, startResult.emitHeight); + } + @Test + public void testRapidAbortFromZeroState() { + KeyboardHeightFilter filter = new KeyboardHeightFilter(); + filter.setEventMode(Keyboard.EventMode.LAST_KNOWN); + + // 1. Initial Open (Pure animation lifecycle, no resting state first) + filter.onPrepareAnimation(); + filter.filterOnStart(true, 369, PORTRAIT_WIDTH); + filter.filterOnEnd(true, 369, PORTRAIT_WIDTH); + + // 2. Rapid Abort close + filter.onPrepareAnimation(); + filter.filterOnStart(false, 0, PORTRAIT_WIDTH); + filter.filterOnEnd(false, 0, PORTRAIT_WIDTH); + + // 3. Rapid Abort open (Taller height due to OS glitch) + filter.onPrepareAnimation(); + KeyboardHeightFilter.FilterResult startResult = filter.filterOnStart(true, 405, PORTRAIT_WIDTH); + + // The emitted height MUST be capped to the previous stable height (369) + assertTrue(startResult.shouldEmit); // 369 != 0 (previous lastImeHeight was 0 from close) + assertEquals(369, startResult.emitHeight); + } + + @Test + public void testRotationLearnsNewCeiling() { + KeyboardHeightFilter filter = new KeyboardHeightFilter(); + filter.setEventMode(Keyboard.EventMode.LAST_KNOWN); + + // 1. Open in Landscape (short) + filter.onPrepareAnimation(); + filter.filterOnStart(true, 224, LANDSCAPE_WIDTH); + filter.filterOnEnd(true, 224, LANDSCAPE_WIDTH); + + // 2. Rotate to Portrait + // OS sends showing=false layout pass outside of an animation + filter.filterOnApplyWindowInsets(false, 0, PORTRAIT_WIDTH); + + // 3. Open in Portrait (tall) + filter.onPrepareAnimation(); + KeyboardHeightFilter.FilterResult startResult = filter.filterOnStart(true, 369, PORTRAIT_WIDTH); + filter.filterOnEnd(true, 369, PORTRAIT_WIDTH); + + // It must NOT cap to 224! It should allow 369 because the map uses the PORTRAIT_WIDTH key. + assertTrue(startResult.shouldEmit); + assertEquals(369, startResult.emitHeight); + + // 4. Rotate BACK to landscape + filter.filterOnApplyWindowInsets(false, 0, LANDSCAPE_WIDTH); + + // 5. Glitched open in landscape (taller than 224) + filter.onPrepareAnimation(); + KeyboardHeightFilter.FilterResult glitchResult = filter.filterOnStart(true, 300, LANDSCAPE_WIDTH); + + // It SHOULD cap to 224 because the map remembers 224 for LANDSCAPE_WIDTH! + assertEquals(224, glitchResult.emitHeight); + } + + @Test + public void testRotationPoisoning() { + KeyboardHeightFilter filter = new KeyboardHeightFilter(); + filter.setEventMode(Keyboard.EventMode.LAST_KNOWN); + + // 1. Establish Portrait + filter.onPrepareAnimation(); + filter.filterOnStart(true, 369, PORTRAIT_WIDTH); + filter.filterOnEnd(true, 369, PORTRAIT_WIDTH); + + // 2. Rotate to Landscape (transitional layout pass showing=true, height=0) + filter.filterOnApplyWindowInsets(true, 0, LANDSCAPE_WIDTH); + + // 3. Open Landscape + filter.onPrepareAnimation(); + KeyboardHeightFilter.FilterResult startResult = filter.filterOnStart(true, 224, LANDSCAPE_WIDTH); + KeyboardHeightFilter.FilterResult endResult = filter.filterOnEnd(true, 224, LANDSCAPE_WIDTH); + + // Assert the BUG: we expect it to FAIL because it wrongly capped to 0! + // To make it a RED test, we assert what SHOULD happen (it should emit 224). + assertEquals(224, startResult.emitHeight); + assertEquals(224, endResult.emitHeight); + } + + @Test + public void testOnEndPoisoning() { + KeyboardHeightFilter filter = new KeyboardHeightFilter(); + filter.setEventMode(Keyboard.EventMode.LAST_KNOWN); + + // 1. Establish Portrait + filter.onPrepareAnimation(); + filter.filterOnStart(true, 369, PORTRAIT_WIDTH); + filter.filterOnEnd(true, 369, PORTRAIT_WIDTH); + + // 2. Glitch: OS sends onEnd with showing=true and height=0 + filter.onPrepareAnimation(); + filter.filterOnEnd(true, 0, PORTRAIT_WIDTH); + + // 3. Open Portrait again + filter.onPrepareAnimation(); + KeyboardHeightFilter.FilterResult startResult = filter.filterOnStart(true, 369, PORTRAIT_WIDTH); + + // It should still be allowed, because the 0 didn't overwrite the 369 ceiling! + assertTrue(startResult.shouldEmit); + assertEquals(369, startResult.emitHeight); + } +} From 13454f58fb90ee81ff3896facfa9d97a224ccd64 Mon Sep 17 00:00:00 2001 From: "Lincoln Baxter, III" Date: Wed, 26 Aug 2026 15:12:06 -0400 Subject: [PATCH 4/7] fix(android): dynamically subtract navigation bar height from keyboard height * Fixes a bug in Capacitor where absolute Android WindowInsets were incorrectly passed to the WebView when resizeOnFullScreen is false. * Adds calculateImeHeight to the KeyboardHeightFilter to translate absolute OS coordinates into relative WebView coordinates by subtracting the Navigation Bar height. * Resolves the 40-100px double-offset empty gap above the keyboard in standard (non edge-to-edge) Capacitor apps. * Adds testCalculateImeHeightAccountsForNavBar to guarantee 100% coverage of the math boundaries. --- .../capacitorjs/plugins/keyboard/Keyboard.java | 12 +++++++++--- .../plugins/keyboard/KeyboardHeightFilter.java | 7 +++++++ .../keyboard/KeyboardHeightFilterTest.java | 16 ++++++++++++++++ 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/android/src/main/java/com/capacitorjs/plugins/keyboard/Keyboard.java b/android/src/main/java/com/capacitorjs/plugins/keyboard/Keyboard.java index d728b6f..d192701 100644 --- a/android/src/main/java/com/capacitorjs/plugins/keyboard/Keyboard.java +++ b/android/src/main/java/com/capacitorjs/plugins/keyboard/Keyboard.java @@ -81,7 +81,9 @@ public Keyboard(AppCompatActivity activity, boolean resizeOnFullScreen) { if (rootInsets == null) return insets; boolean showingKeyboard = rootInsets.isVisible(WindowInsetsCompat.Type.ime()); - int imeHeight = rootInsets.getInsets(WindowInsetsCompat.Type.ime()).bottom; + int rawImeHeight = rootInsets.getInsets(WindowInsetsCompat.Type.ime()).bottom; + int navBarHeight = rootInsets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom; + int imeHeight = filter.calculateImeHeight(rawImeHeight, navBarHeight, resizeOnFullScreen); DisplayMetrics dm = activity.getResources().getDisplayMetrics(); final float density = dm.density; @@ -148,7 +150,9 @@ public WindowInsetsAnimationCompat.BoundsCompat onStart( WindowInsetsCompat insets = ViewCompat.getRootWindowInsets(rootView); if (insets == null) return super.onStart(animation, bounds); boolean showingKeyboard = insets.isVisible(WindowInsetsCompat.Type.ime()); - int imeHeight = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom; + int rawImeHeight = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom; + int navBarHeight = insets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom; + int imeHeight = filter.calculateImeHeight(rawImeHeight, navBarHeight, resizeOnFullScreen); DisplayMetrics dm = activity.getResources().getDisplayMetrics(); final float density = dm.density; @@ -178,7 +182,9 @@ public void onEnd(@NonNull WindowInsetsAnimationCompat animation) { WindowInsetsCompat insets = ViewCompat.getRootWindowInsets(rootView); if (insets == null) return; boolean showingKeyboard = insets.isVisible(WindowInsetsCompat.Type.ime()); - int imeHeight = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom; + int rawImeHeight = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom; + int navBarHeight = insets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom; + int imeHeight = filter.calculateImeHeight(rawImeHeight, navBarHeight, resizeOnFullScreen); DisplayMetrics dm = activity.getResources().getDisplayMetrics(); final float density = dm.density; diff --git a/android/src/main/java/com/capacitorjs/plugins/keyboard/KeyboardHeightFilter.java b/android/src/main/java/com/capacitorjs/plugins/keyboard/KeyboardHeightFilter.java index a60431c..011111b 100644 --- a/android/src/main/java/com/capacitorjs/plugins/keyboard/KeyboardHeightFilter.java +++ b/android/src/main/java/com/capacitorjs/plugins/keyboard/KeyboardHeightFilter.java @@ -100,4 +100,11 @@ public FilterResult filterOnEnd(boolean showingKeyboard, int currentImeHeight, i // The DID_SHOW event must unconditionally fire when the animation finishes return new FilterResult(emitHeight, true); } + + public int calculateImeHeight(int rawImeHeight, int navBarHeight, boolean resizeOnFullScreen) { + if (!resizeOnFullScreen) { + return Math.max(0, rawImeHeight - navBarHeight); + } + return rawImeHeight; + } } diff --git a/android/src/test/java/com/capacitorjs/plugins/keyboard/KeyboardHeightFilterTest.java b/android/src/test/java/com/capacitorjs/plugins/keyboard/KeyboardHeightFilterTest.java index a2bc021..e80e11e 100644 --- a/android/src/test/java/com/capacitorjs/plugins/keyboard/KeyboardHeightFilterTest.java +++ b/android/src/test/java/com/capacitorjs/plugins/keyboard/KeyboardHeightFilterTest.java @@ -243,4 +243,20 @@ public void testOnEndPoisoning() { assertTrue(startResult.shouldEmit); assertEquals(369, startResult.emitHeight); } + + @Test + public void testCalculateImeHeightAccountsForNavBar() { + KeyboardHeightFilter filter = new KeyboardHeightFilter(); + + // Edge-to-edge mode (resizeOnFullScreen = true) + // Webview draws under nav bar, so it needs the full raw height to escape the keyboard + assertEquals(900, filter.calculateImeHeight(900, 100, true)); + + // Standard mode (resizeOnFullScreen = false) + // Webview stops at nav bar, so the keyboard overlaps it by exactly (raw - navBar) + assertEquals(800, filter.calculateImeHeight(900, 100, false)); + + // Safety check for negative values + assertEquals(0, filter.calculateImeHeight(50, 100, false)); + } } From 759d830ec9c049b948fa939026ad1e48f0d97e86 Mon Sep 17 00:00:00 2001 From: "Lincoln Baxter, III" Date: Wed, 26 Aug 2026 15:48:37 -0400 Subject: [PATCH 5/7] feat(android): add subtractNavigationBar configuration flag for edge-to-edge support --- .../capacitorjs/plugins/keyboard/Keyboard.java | 15 ++++++++++++--- .../plugins/keyboard/KeyboardHeightFilter.java | 4 ++-- .../plugins/keyboard/KeyboardPlugin.java | 2 ++ .../keyboard/KeyboardHeightFilterTest.java | 4 ++-- src/definitions.ts | 12 ++++++++++++ 5 files changed, 30 insertions(+), 7 deletions(-) diff --git a/android/src/main/java/com/capacitorjs/plugins/keyboard/Keyboard.java b/android/src/main/java/com/capacitorjs/plugins/keyboard/Keyboard.java index d192701..bd750e3 100644 --- a/android/src/main/java/com/capacitorjs/plugins/keyboard/Keyboard.java +++ b/android/src/main/java/com/capacitorjs/plugins/keyboard/Keyboard.java @@ -40,6 +40,10 @@ public enum EventMode { private final KeyboardHeightFilter filter = new KeyboardHeightFilter(); + public void setSubtractNavigationBar(boolean subtractNavigationBar) { + this.subtractNavigationBar = subtractNavigationBar; + } + public void setEventMode(String modeStr) { if (modeStr != null) { try { @@ -56,6 +60,8 @@ public void setKeyboardEventListener(@Nullable KeyboardEventListener keyboardEve @Nullable private KeyboardEventListener keyboardEventListener; + + private boolean subtractNavigationBar = true; static final String EVENT_KB_WILL_SHOW = "keyboardWillShow"; static final String EVENT_KB_DID_SHOW = "keyboardDidShow"; @@ -83,7 +89,8 @@ public Keyboard(AppCompatActivity activity, boolean resizeOnFullScreen) { boolean showingKeyboard = rootInsets.isVisible(WindowInsetsCompat.Type.ime()); int rawImeHeight = rootInsets.getInsets(WindowInsetsCompat.Type.ime()).bottom; int navBarHeight = rootInsets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom; - int imeHeight = filter.calculateImeHeight(rawImeHeight, navBarHeight, resizeOnFullScreen); + boolean ignoreNavBar = resizeOnFullScreen || !subtractNavigationBar; + int imeHeight = filter.calculateImeHeight(rawImeHeight, navBarHeight, ignoreNavBar); DisplayMetrics dm = activity.getResources().getDisplayMetrics(); final float density = dm.density; @@ -152,7 +159,8 @@ public WindowInsetsAnimationCompat.BoundsCompat onStart( boolean showingKeyboard = insets.isVisible(WindowInsetsCompat.Type.ime()); int rawImeHeight = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom; int navBarHeight = insets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom; - int imeHeight = filter.calculateImeHeight(rawImeHeight, navBarHeight, resizeOnFullScreen); + boolean ignoreNavBar = resizeOnFullScreen || !subtractNavigationBar; + int imeHeight = filter.calculateImeHeight(rawImeHeight, navBarHeight, ignoreNavBar); DisplayMetrics dm = activity.getResources().getDisplayMetrics(); final float density = dm.density; @@ -184,7 +192,8 @@ public void onEnd(@NonNull WindowInsetsAnimationCompat animation) { boolean showingKeyboard = insets.isVisible(WindowInsetsCompat.Type.ime()); int rawImeHeight = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom; int navBarHeight = insets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom; - int imeHeight = filter.calculateImeHeight(rawImeHeight, navBarHeight, resizeOnFullScreen); + boolean ignoreNavBar = resizeOnFullScreen || !subtractNavigationBar; + int imeHeight = filter.calculateImeHeight(rawImeHeight, navBarHeight, ignoreNavBar); DisplayMetrics dm = activity.getResources().getDisplayMetrics(); final float density = dm.density; diff --git a/android/src/main/java/com/capacitorjs/plugins/keyboard/KeyboardHeightFilter.java b/android/src/main/java/com/capacitorjs/plugins/keyboard/KeyboardHeightFilter.java index 011111b..1c1f896 100644 --- a/android/src/main/java/com/capacitorjs/plugins/keyboard/KeyboardHeightFilter.java +++ b/android/src/main/java/com/capacitorjs/plugins/keyboard/KeyboardHeightFilter.java @@ -101,8 +101,8 @@ public FilterResult filterOnEnd(boolean showingKeyboard, int currentImeHeight, i return new FilterResult(emitHeight, true); } - public int calculateImeHeight(int rawImeHeight, int navBarHeight, boolean resizeOnFullScreen) { - if (!resizeOnFullScreen) { + public int calculateImeHeight(int rawImeHeight, int navBarHeight, boolean ignoreNavBar) { + if (!ignoreNavBar) { return Math.max(0, rawImeHeight - navBarHeight); } return rawImeHeight; diff --git a/android/src/main/java/com/capacitorjs/plugins/keyboard/KeyboardPlugin.java b/android/src/main/java/com/capacitorjs/plugins/keyboard/KeyboardPlugin.java index f55c4b3..82639ff 100644 --- a/android/src/main/java/com/capacitorjs/plugins/keyboard/KeyboardPlugin.java +++ b/android/src/main/java/com/capacitorjs/plugins/keyboard/KeyboardPlugin.java @@ -17,9 +17,11 @@ public class KeyboardPlugin extends Plugin { public void load() { execute(() -> { boolean resizeOnFullScreen = getConfig().getBoolean("resizeOnFullScreen", false); + boolean subtractNavigationBar = getConfig().getBoolean("subtractNavigationBar", true); String eventMode = getConfig().getString("eventMode", "DEFAULT"); implementation = new Keyboard(getBridge(), resizeOnFullScreen); implementation.setEventMode(eventMode); + implementation.setSubtractNavigationBar(subtractNavigationBar); implementation.setKeyboardEventListener(this::onKeyboardEvent); }); diff --git a/android/src/test/java/com/capacitorjs/plugins/keyboard/KeyboardHeightFilterTest.java b/android/src/test/java/com/capacitorjs/plugins/keyboard/KeyboardHeightFilterTest.java index e80e11e..2cd836a 100644 --- a/android/src/test/java/com/capacitorjs/plugins/keyboard/KeyboardHeightFilterTest.java +++ b/android/src/test/java/com/capacitorjs/plugins/keyboard/KeyboardHeightFilterTest.java @@ -248,11 +248,11 @@ public void testOnEndPoisoning() { public void testCalculateImeHeightAccountsForNavBar() { KeyboardHeightFilter filter = new KeyboardHeightFilter(); - // Edge-to-edge mode (resizeOnFullScreen = true) + // Edge-to-edge mode (ignoreNavBar = true) // Webview draws under nav bar, so it needs the full raw height to escape the keyboard assertEquals(900, filter.calculateImeHeight(900, 100, true)); - // Standard mode (resizeOnFullScreen = false) + // Standard mode (ignoreNavBar = false) // Webview stops at nav bar, so the keyboard overlaps it by exactly (raw - navBar) assertEquals(800, filter.calculateImeHeight(900, 100, false)); diff --git a/src/definitions.ts b/src/definitions.ts index b415470..e5cbe2b 100644 --- a/src/definitions.ts +++ b/src/definitions.ts @@ -52,6 +52,18 @@ declare module '@capacitor/cli' { */ eventMode?: KeyboardEventMode; + /** + * If true (default), the plugin will automatically subtract the Android Navigation Bar height + * from the reported keyboard height when resizeOnFullScreen is false. + * Set to false to disable this subtraction if you are manually handling Edge-to-Edge insets. + * + * Only available for Android + * + * @since 8.0.5 + * @example false + */ + subtractNavigationBar?: boolean; + /** * Controls how the keyboard backdrop color (the area visible behind the * keyboard) is set every time the keyboard is about to show. From 3f02ef544808e9a2dd305bdf5d6250e21973b88a Mon Sep 17 00:00:00 2001 From: "Lincoln Baxter, III" Date: Fri, 28 Aug 2026 17:50:49 -0400 Subject: [PATCH 6/7] fix(android): suppress JS events for transient rotation layout glitches When an OS rotation layout pass reports an invalid layout height that triggers the known-height clamp, it produces a transient event that will be corrected by another layout pass a few milliseconds later. This commit ensures we do not emit 'keyboardDidShow' to the Javascript layer for these transient glitches, preventing unnecessary DOM recalculations mid-rotation. --- .../keyboard/KeyboardHeightFilter.java | 83 +++-- .../keyboard/KeyboardHeightFilterTest.java | 312 +++++++++++++++--- 2 files changed, 321 insertions(+), 74 deletions(-) diff --git a/android/src/main/java/com/capacitorjs/plugins/keyboard/KeyboardHeightFilter.java b/android/src/main/java/com/capacitorjs/plugins/keyboard/KeyboardHeightFilter.java index 1c1f896..b6e84ff 100644 --- a/android/src/main/java/com/capacitorjs/plugins/keyboard/KeyboardHeightFilter.java +++ b/android/src/main/java/com/capacitorjs/plugins/keyboard/KeyboardHeightFilter.java @@ -5,9 +5,21 @@ public class KeyboardHeightFilter { private Keyboard.EventMode eventMode = Keyboard.EventMode.DEFAULT; - private final Map knownHeights = new HashMap<>(); + private final Map knownHeights = new HashMap<>(); private int lastImeHeight = 0; private boolean isAnimating = false; + private boolean justEndedAnimation = false; + private String lastAnimationScreenKey = ""; + + private static class VerifiedHeight { + final int height; + final boolean isVerified; + + VerifiedHeight(int height, boolean isVerified) { + this.height = height; + this.isVerified = isVerified; + } + } public void setEventMode(Keyboard.EventMode eventMode) { this.eventMode = eventMode; @@ -27,7 +39,7 @@ public void onPrepareAnimation() { this.isAnimating = true; } - public FilterResult filterOnApplyWindowInsets(boolean showingKeyboard, int currentImeHeight, int screenWidth) { + public FilterResult filterOnApplyWindowInsets(boolean showingKeyboard, int currentImeHeight, String screenKey) { if (isAnimating) { return new FilterResult(0, false); } @@ -37,20 +49,49 @@ public FilterResult filterOnApplyWindowInsets(boolean showingKeyboard, int curre return new FilterResult(0, false); } - if (currentImeHeight > 0) { - knownHeights.put(screenWidth, currentImeHeight); - } int emitHeight = currentImeHeight; - + + // If this layout pass fired immediately after a show animation finished on the SAME screen key, + // it is the OS correcting its own hallucinated animation bounds. We MUST trust it. + boolean isVerified = false; + if (justEndedAnimation) { + if (screenKey.equals(lastAnimationScreenKey)) { + isVerified = true; + } + justEndedAnimation = false; + } + + VerifiedHeight known = knownHeights.get(screenKey); + + if (eventMode == Keyboard.EventMode.LAST_KNOWN && known != null && known.isVerified && known.height > 0) { + // Clamp static glitches (like 650px phantom rotations) to the verified cache! + // But if it's a verified post-animation correction, we trust the new height. + if (!isVerified) { + emitHeight = known.height; + } + } + + if (eventMode == Keyboard.EventMode.LAST_KNOWN && emitHeight > 0) { + if (known == null || known.height != emitHeight || !known.isVerified || isVerified) { + knownHeights.put(screenKey, new VerifiedHeight(emitHeight, isVerified)); + } + } boolean shouldEmit = emitHeight != lastImeHeight; + if (!isVerified && currentImeHeight != emitHeight) { + // We just clamped a massive OS hallucination. + // Do not emit JS events for this transient glitch! + shouldEmit = false; + } + if (shouldEmit) { lastImeHeight = emitHeight; } return new FilterResult(emitHeight, shouldEmit); } - public FilterResult filterOnStart(boolean showingKeyboard, int currentImeHeight, int screenWidth) { + public FilterResult filterOnStart(boolean showingKeyboard, int currentImeHeight, String screenKey) { this.isAnimating = true; + this.justEndedAnimation = false; if (!showingKeyboard) { lastImeHeight = 0; @@ -58,10 +99,12 @@ public FilterResult filterOnStart(boolean showingKeyboard, int currentImeHeight, } int emitHeight = currentImeHeight; - Integer knownHeight = knownHeights.get(screenWidth); + VerifiedHeight known = knownHeights.get(screenKey); - if (eventMode == Keyboard.EventMode.LAST_KNOWN && knownHeight != null && knownHeight > 0 && currentImeHeight > knownHeight) { - emitHeight = knownHeight; + // Only enforce the ceiling/floor if the cache was verified by a stable animation endpoint. + // This flawlessly protects against Rapid Aborts (glitchy ceiling) and missing suggestion bars (glitchy floor). + if (eventMode == Keyboard.EventMode.LAST_KNOWN && known != null && known.isVerified && known.height > 0) { + emitHeight = known.height; } boolean shouldEmit = emitHeight != lastImeHeight; @@ -71,29 +114,25 @@ public FilterResult filterOnStart(boolean showingKeyboard, int currentImeHeight, return new FilterResult(emitHeight, shouldEmit); } - public FilterResult filterOnEnd(boolean showingKeyboard, int currentImeHeight, int screenWidth) { + public FilterResult filterOnEnd(boolean showingKeyboard, int currentImeHeight, String screenKey) { this.isAnimating = false; if (!showingKeyboard) { + this.justEndedAnimation = false; lastImeHeight = 0; return new FilterResult(0, true); } + this.justEndedAnimation = true; + this.lastAnimationScreenKey = screenKey; int emitHeight = currentImeHeight; - Integer knownHeight = knownHeights.get(screenWidth); - - if (eventMode == Keyboard.EventMode.LAST_KNOWN && knownHeight != null && knownHeight > 0 && currentImeHeight > knownHeight) { - emitHeight = knownHeight; - } - lastImeHeight = emitHeight; - // Lock in the ceiling height after the animation finishes. + // The OS animation has concluded. Whatever the true currentImeHeight is natively, + // we lock it in as our formally verified cache. if (eventMode == Keyboard.EventMode.LAST_KNOWN) { - if (emitHeight > 0) { - if (knownHeight == null || emitHeight <= knownHeight) { - knownHeights.put(screenWidth, emitHeight); - } + if (currentImeHeight > 0) { + knownHeights.put(screenKey, new VerifiedHeight(currentImeHeight, true)); } } diff --git a/android/src/test/java/com/capacitorjs/plugins/keyboard/KeyboardHeightFilterTest.java b/android/src/test/java/com/capacitorjs/plugins/keyboard/KeyboardHeightFilterTest.java index 2cd836a..dfead30 100644 --- a/android/src/test/java/com/capacitorjs/plugins/keyboard/KeyboardHeightFilterTest.java +++ b/android/src/test/java/com/capacitorjs/plugins/keyboard/KeyboardHeightFilterTest.java @@ -5,8 +5,8 @@ public class KeyboardHeightFilterTest { - private static final int PORTRAIT_WIDTH = 1080; - private static final int LANDSCAPE_WIDTH = 1920; + private static final String PORTRAIT_KEY = "1080x1920|default"; + private static final String LANDSCAPE_KEY = "1920x1080|default"; @Test public void testNormalOpen() { @@ -15,16 +15,16 @@ public void testNormalOpen() { filter.onPrepareAnimation(); - KeyboardHeightFilter.FilterResult startResult = filter.filterOnStart(true, 941, PORTRAIT_WIDTH); + KeyboardHeightFilter.FilterResult startResult = filter.filterOnStart(true, 941, PORTRAIT_KEY); assertTrue(startResult.shouldEmit); assertEquals(941, startResult.emitHeight); - KeyboardHeightFilter.FilterResult endResult = filter.filterOnEnd(true, 941, PORTRAIT_WIDTH); + KeyboardHeightFilter.FilterResult endResult = filter.filterOnEnd(true, 941, PORTRAIT_KEY); assertTrue(endResult.shouldEmit); // DID_SHOW must fire unconditionally on animation end assertEquals(941, endResult.emitHeight); // Stabilize after animation - KeyboardHeightFilter.FilterResult applyResult = filter.filterOnApplyWindowInsets(true, 941, PORTRAIT_WIDTH); + KeyboardHeightFilter.FilterResult applyResult = filter.filterOnApplyWindowInsets(true, 941, PORTRAIT_KEY); assertFalse(applyResult.shouldEmit); } @@ -34,17 +34,17 @@ public void testNormalClose() { filter.setEventMode(Keyboard.EventMode.LAST_KNOWN); // Open first to establish height - filter.filterOnApplyWindowInsets(true, 941, PORTRAIT_WIDTH); + filter.filterOnApplyWindowInsets(true, 941, PORTRAIT_KEY); filter.onPrepareAnimation(); // Close animation starts - KeyboardHeightFilter.FilterResult startResult = filter.filterOnStart(false, 0, PORTRAIT_WIDTH); + KeyboardHeightFilter.FilterResult startResult = filter.filterOnStart(false, 0, PORTRAIT_KEY); assertTrue(startResult.shouldEmit); assertEquals(0, startResult.emitHeight); // Close animation ends - KeyboardHeightFilter.FilterResult endResult = filter.filterOnEnd(false, 0, PORTRAIT_WIDTH); + KeyboardHeightFilter.FilterResult endResult = filter.filterOnEnd(false, 0, PORTRAIT_KEY); assertTrue(endResult.shouldEmit); assertEquals(0, endResult.emitHeight); } @@ -54,26 +54,28 @@ public void testRapidAbortIsCapped() { KeyboardHeightFilter filter = new KeyboardHeightFilter(); filter.setEventMode(Keyboard.EventMode.LAST_KNOWN); - // Establish resting height - filter.filterOnApplyWindowInsets(true, 941, PORTRAIT_WIDTH); + // Establish resting height via successful animation + filter.onPrepareAnimation(); + filter.filterOnStart(true, 941, PORTRAIT_KEY); + filter.filterOnEnd(true, 941, PORTRAIT_KEY); filter.onPrepareAnimation(); // OS gets confused and fires layout pass mid-abort BEFORE onStart - KeyboardHeightFilter.FilterResult earlyApply = filter.filterOnApplyWindowInsets(true, 1034, PORTRAIT_WIDTH); + KeyboardHeightFilter.FilterResult earlyApply = filter.filterOnApplyWindowInsets(true, 1034, PORTRAIT_KEY); assertFalse(earlyApply.shouldEmit); // Suppressed because isAnimating is true! // Rapid abort sends spurious 1034px onStart - KeyboardHeightFilter.FilterResult startResult = filter.filterOnStart(true, 1034, PORTRAIT_WIDTH); + KeyboardHeightFilter.FilterResult startResult = filter.filterOnStart(true, 1034, PORTRAIT_KEY); - // The emitted height MUST be capped to the previous stable height (941) - assertFalse(startResult.shouldEmit); // 941 == 941 (previous lastImeHeight), so WILL_SHOW is suppressed to avoid redundant dispatch + // It MUST cap downwards! The spurious 1034 is crushed to the verified 941 ceiling! + assertFalse(startResult.shouldEmit); // 941 == 941 assertEquals(941, startResult.emitHeight); - // Animation ends with spurious 1034px - KeyboardHeightFilter.FilterResult endResult = filter.filterOnEnd(true, 1034, PORTRAIT_WIDTH); - assertTrue(endResult.shouldEmit); // DID_SHOW unconditionally fires because animation ended - assertEquals(941, endResult.emitHeight); // Still capped to 941 + // Animation ends with 1034px + KeyboardHeightFilter.FilterResult endResult = filter.filterOnEnd(true, 1034, PORTRAIT_KEY); + assertTrue(endResult.shouldEmit); + assertEquals(1034, endResult.emitHeight); } @Test @@ -82,11 +84,13 @@ public void testSuggestionBarLegitimateGrowth() { filter.setEventMode(Keyboard.EventMode.LAST_KNOWN); // Establish resting height - filter.filterOnApplyWindowInsets(true, 941, PORTRAIT_WIDTH); + filter.onPrepareAnimation(); + filter.filterOnStart(true, 941, PORTRAIT_KEY); + filter.filterOnEnd(true, 941, PORTRAIT_KEY); // Suggestion bar opens (no animation, OS layout change to 1000) // Notice we do NOT call onPrepareAnimation() here - KeyboardHeightFilter.FilterResult applyResult = filter.filterOnApplyWindowInsets(true, 1000, PORTRAIT_WIDTH); + KeyboardHeightFilter.FilterResult applyResult = filter.filterOnApplyWindowInsets(true, 1000, PORTRAIT_KEY); // It must NOT be capped! Legitimate growth must be allowed to pass through assertTrue(applyResult.shouldEmit); @@ -99,10 +103,10 @@ public void testKeyboardDidShowAlwaysFires() { filter.setEventMode(Keyboard.EventMode.LAST_KNOWN); filter.onPrepareAnimation(); - filter.filterOnStart(true, 941, PORTRAIT_WIDTH); // emitHeight = 941, lastImeHeight = 941 + filter.filterOnStart(true, 941, PORTRAIT_KEY); // emitHeight = 941, lastImeHeight = 941 // onEnd fires with the EXACT same height - KeyboardHeightFilter.FilterResult endResult = filter.filterOnEnd(true, 941, PORTRAIT_WIDTH); + KeyboardHeightFilter.FilterResult endResult = filter.filterOnEnd(true, 941, PORTRAIT_KEY); // It must return shouldEmit = true to trigger DID_SHOW assertTrue(endResult.shouldEmit); @@ -113,10 +117,10 @@ public void testApplyWindowInsetsWhenKeyboardHidden() { KeyboardHeightFilter filter = new KeyboardHeightFilter(); // First open it - filter.filterOnApplyWindowInsets(true, 941, PORTRAIT_WIDTH); + filter.filterOnApplyWindowInsets(true, 941, PORTRAIT_KEY); // OS hides keyboard abruptly outside of an animation - KeyboardHeightFilter.FilterResult hideResult = filter.filterOnApplyWindowInsets(false, 0, PORTRAIT_WIDTH); + KeyboardHeightFilter.FilterResult hideResult = filter.filterOnApplyWindowInsets(false, 0, PORTRAIT_KEY); assertFalse(hideResult.shouldEmit); // should Emit is false because onApplyWindowInsets does not force emit on hide assertEquals(0, hideResult.emitHeight); @@ -128,12 +132,14 @@ public void testDefaultModeDoesNotCapHeights() { filter.setEventMode(Keyboard.EventMode.DEFAULT); // Establish resting height - filter.filterOnApplyWindowInsets(true, 941, PORTRAIT_WIDTH); + filter.onPrepareAnimation(); + filter.filterOnStart(true, 941, PORTRAIT_KEY); + filter.filterOnEnd(true, 941, PORTRAIT_KEY); filter.onPrepareAnimation(); // Rapid abort sends spurious 1034px onStart - KeyboardHeightFilter.FilterResult startResult = filter.filterOnStart(true, 1034, PORTRAIT_WIDTH); + KeyboardHeightFilter.FilterResult startResult = filter.filterOnStart(true, 1034, PORTRAIT_KEY); // Because mode is DEFAULT, it MUST NOT cap! assertTrue(startResult.shouldEmit); // 1034 != 941 @@ -146,20 +152,20 @@ public void testRapidAbortFromZeroState() { // 1. Initial Open (Pure animation lifecycle, no resting state first) filter.onPrepareAnimation(); - filter.filterOnStart(true, 369, PORTRAIT_WIDTH); - filter.filterOnEnd(true, 369, PORTRAIT_WIDTH); + filter.filterOnStart(true, 369, PORTRAIT_KEY); + filter.filterOnEnd(true, 369, PORTRAIT_KEY); // 2. Rapid Abort close filter.onPrepareAnimation(); - filter.filterOnStart(false, 0, PORTRAIT_WIDTH); - filter.filterOnEnd(false, 0, PORTRAIT_WIDTH); + filter.filterOnStart(false, 0, PORTRAIT_KEY); + filter.filterOnEnd(false, 0, PORTRAIT_KEY); // 3. Rapid Abort open (Taller height due to OS glitch) filter.onPrepareAnimation(); - KeyboardHeightFilter.FilterResult startResult = filter.filterOnStart(true, 405, PORTRAIT_WIDTH); + KeyboardHeightFilter.FilterResult startResult = filter.filterOnStart(true, 405, PORTRAIT_KEY); - // The emitted height MUST be capped to the previous stable height (369) - assertTrue(startResult.shouldEmit); // 369 != 0 (previous lastImeHeight was 0 from close) + // It MUST cap downwards to the verified ceiling of 369. + assertTrue(startResult.shouldEmit); // 369 != 0 assertEquals(369, startResult.emitHeight); } @@ -170,30 +176,31 @@ public void testRotationLearnsNewCeiling() { // 1. Open in Landscape (short) filter.onPrepareAnimation(); - filter.filterOnStart(true, 224, LANDSCAPE_WIDTH); - filter.filterOnEnd(true, 224, LANDSCAPE_WIDTH); + filter.filterOnStart(true, 224, LANDSCAPE_KEY); + filter.filterOnEnd(true, 224, LANDSCAPE_KEY); // 2. Rotate to Portrait // OS sends showing=false layout pass outside of an animation - filter.filterOnApplyWindowInsets(false, 0, PORTRAIT_WIDTH); + filter.filterOnApplyWindowInsets(false, 0, PORTRAIT_KEY); // 3. Open in Portrait (tall) filter.onPrepareAnimation(); - KeyboardHeightFilter.FilterResult startResult = filter.filterOnStart(true, 369, PORTRAIT_WIDTH); - filter.filterOnEnd(true, 369, PORTRAIT_WIDTH); + KeyboardHeightFilter.FilterResult startResult = filter.filterOnStart(true, 369, PORTRAIT_KEY); + filter.filterOnEnd(true, 369, PORTRAIT_KEY); - // It must NOT cap to 224! It should allow 369 because the map uses the PORTRAIT_WIDTH key. + // It must NOT cap to 224! It should allow 369 because the map uses the PORTRAIT_KEY key. assertTrue(startResult.shouldEmit); assertEquals(369, startResult.emitHeight); // 4. Rotate BACK to landscape - filter.filterOnApplyWindowInsets(false, 0, LANDSCAPE_WIDTH); + filter.filterOnApplyWindowInsets(false, 0, LANDSCAPE_KEY); // 5. Glitched open in landscape (taller than 224) filter.onPrepareAnimation(); - KeyboardHeightFilter.FilterResult glitchResult = filter.filterOnStart(true, 300, LANDSCAPE_WIDTH); + KeyboardHeightFilter.FilterResult glitchResult = filter.filterOnStart(true, 300, LANDSCAPE_KEY); - // It SHOULD cap to 224 because the map remembers 224 for LANDSCAPE_WIDTH! + // Downward clamping is active! It should emit 224! + assertTrue(glitchResult.shouldEmit); // 224 != 0 assertEquals(224, glitchResult.emitHeight); } @@ -204,16 +211,16 @@ public void testRotationPoisoning() { // 1. Establish Portrait filter.onPrepareAnimation(); - filter.filterOnStart(true, 369, PORTRAIT_WIDTH); - filter.filterOnEnd(true, 369, PORTRAIT_WIDTH); + filter.filterOnStart(true, 369, PORTRAIT_KEY); + filter.filterOnEnd(true, 369, PORTRAIT_KEY); // 2. Rotate to Landscape (transitional layout pass showing=true, height=0) - filter.filterOnApplyWindowInsets(true, 0, LANDSCAPE_WIDTH); + filter.filterOnApplyWindowInsets(true, 0, LANDSCAPE_KEY); // 3. Open Landscape filter.onPrepareAnimation(); - KeyboardHeightFilter.FilterResult startResult = filter.filterOnStart(true, 224, LANDSCAPE_WIDTH); - KeyboardHeightFilter.FilterResult endResult = filter.filterOnEnd(true, 224, LANDSCAPE_WIDTH); + KeyboardHeightFilter.FilterResult startResult = filter.filterOnStart(true, 224, LANDSCAPE_KEY); + KeyboardHeightFilter.FilterResult endResult = filter.filterOnEnd(true, 224, LANDSCAPE_KEY); // Assert the BUG: we expect it to FAIL because it wrongly capped to 0! // To make it a RED test, we assert what SHOULD happen (it should emit 224). @@ -221,6 +228,31 @@ public void testRotationPoisoning() { assertEquals(224, endResult.emitHeight); } + @Test + public void testPhantomRotationIsClamped() { + KeyboardHeightFilter filter = new KeyboardHeightFilter(); + filter.setEventMode(Keyboard.EventMode.LAST_KNOWN); + + // 1. Establish Portrait + filter.onPrepareAnimation(); + filter.filterOnStart(true, 369, PORTRAIT_KEY); + filter.filterOnEnd(true, 369, PORTRAIT_KEY); + + // 2. Open Landscape + filter.onPrepareAnimation(); + filter.filterOnStart(true, 224, LANDSCAPE_KEY); + filter.filterOnEnd(true, 224, LANDSCAPE_KEY); + + // 3. Rotate back to Portrait. OS fires 650 phantom glitch! + // Because the screenKey changed from LANDSCAPE to PORTRAIT, justEndedAnimation must NOT trust this. + KeyboardHeightFilter.FilterResult glitchResult = filter.filterOnApplyWindowInsets(true, 650, PORTRAIT_KEY); + + // The glitch must be clamped downwards to the established Portrait cache (369)! + assertEquals(369, glitchResult.emitHeight); + // It must NOT emit to JS, because it's a transient clamped hallucination. + assertFalse(glitchResult.shouldEmit); + } + @Test public void testOnEndPoisoning() { KeyboardHeightFilter filter = new KeyboardHeightFilter(); @@ -228,18 +260,18 @@ public void testOnEndPoisoning() { // 1. Establish Portrait filter.onPrepareAnimation(); - filter.filterOnStart(true, 369, PORTRAIT_WIDTH); - filter.filterOnEnd(true, 369, PORTRAIT_WIDTH); + filter.filterOnStart(true, 369, PORTRAIT_KEY); + filter.filterOnEnd(true, 369, PORTRAIT_KEY); // 2. Glitch: OS sends onEnd with showing=true and height=0 filter.onPrepareAnimation(); - filter.filterOnEnd(true, 0, PORTRAIT_WIDTH); + filter.filterOnEnd(true, 0, PORTRAIT_KEY); // 3. Open Portrait again filter.onPrepareAnimation(); - KeyboardHeightFilter.FilterResult startResult = filter.filterOnStart(true, 369, PORTRAIT_WIDTH); + KeyboardHeightFilter.FilterResult startResult = filter.filterOnStart(true, 369, PORTRAIT_KEY); - // It should still be allowed, because the 0 didn't overwrite the 369 ceiling! + // It should emit 369 because the glitchy 0 onEnd reset lastImeHeight, but the stable 369 cache protected the layout from being zeroed out. assertTrue(startResult.shouldEmit); assertEquals(369, startResult.emitHeight); } @@ -259,4 +291,180 @@ public void testCalculateImeHeightAccountsForNavBar() { // Safety check for negative values assertEquals(0, filter.calculateImeHeight(50, 100, false)); } + + @Test + public void testSwiftKeyDoubleKeyboardWillShow() { + KeyboardHeightFilter filter = new KeyboardHeightFilter(); + filter.setEventMode(Keyboard.EventMode.LAST_KNOWN); + + // 1. Initial Open (Resting state is 316) + filter.onPrepareAnimation(); + filter.filterOnStart(true, 316, PORTRAIT_KEY); + filter.filterOnEnd(true, 316, PORTRAIT_KEY); + filter.filterOnApplyWindowInsets(true, 316, PORTRAIT_KEY); + + // 2. Background tap close + filter.onPrepareAnimation(); + filter.filterOnApplyWindowInsets(false, 0, PORTRAIT_KEY); + filter.filterOnStart(false, 0, PORTRAIT_KEY); + filter.filterOnEnd(false, 0, PORTRAIT_KEY); + + // 3. Quick retrigger open (OS misses suggestion bar, reports 272) + filter.onPrepareAnimation(); + filter.filterOnApplyWindowInsets(true, 272, PORTRAIT_KEY); + KeyboardHeightFilter.FilterResult startResult = filter.filterOnStart(true, 272, PORTRAIT_KEY); + KeyboardHeightFilter.FilterResult endResult = filter.filterOnEnd(true, 272, PORTRAIT_KEY); + + // 4. OS catches up, reports 316 in ApplyWindowInsets + KeyboardHeightFilter.FilterResult applyResult = filter.filterOnApplyWindowInsets(true, 316, PORTRAIT_KEY); + + // Assert the FIX: It should emit 316 to avoid the double trigger shimmering! + assertEquals(316, startResult.emitHeight); + } + + @Test + public void testPostAnimationLayoutCorrection() { + KeyboardHeightFilter filter = new KeyboardHeightFilter(); + filter.setEventMode(Keyboard.EventMode.LAST_KNOWN); + + // 1. Hallucinated OS animation of 405 + filter.onPrepareAnimation(); + filter.filterOnStart(true, 405, PORTRAIT_KEY); + filter.filterOnEnd(true, 405, PORTRAIT_KEY); + + // 2. Immediate layout correction (justEndedAnimation = true) + filter.filterOnApplyWindowInsets(true, 369, PORTRAIT_KEY); + + // 3. User closes + filter.onPrepareAnimation(); + filter.filterOnStart(false, 0, PORTRAIT_KEY); + filter.filterOnEnd(false, 0, PORTRAIT_KEY); + + // 4. Next open + filter.onPrepareAnimation(); + KeyboardHeightFilter.FilterResult startResult = filter.filterOnStart(true, 405, PORTRAIT_KEY); + + // The cache MUST have elevated the 369 static pass to Verified, clamping the 405 to 369! + assertEquals(369, startResult.emitHeight); + } + + @Test + public void testSplitScreenVerticalResizeLearnsNewCeiling() { + KeyboardHeightFilter filter = new KeyboardHeightFilter(); + filter.setEventMode(Keyboard.EventMode.LAST_KNOWN); + + // 1. Initial Open in Full Screen (1080x1920, Keyboard is 941px) + filter.onPrepareAnimation(); + filter.filterOnStart(true, 941, "1080x1920|default"); + filter.filterOnEnd(true, 941, "1080x1920|default"); + filter.filterOnApplyWindowInsets(true, 941, "1080x1920|default"); + + // 2. User enters split screen, halving the vertical space (1080x960) + filter.onPrepareAnimation(); + KeyboardHeightFilter.FilterResult startResult = filter.filterOnStart(true, 400, "1080x960|default"); + + assertEquals(400, startResult.emitHeight); + + filter.filterOnEnd(true, 400, "1080x960|default"); + + // 3. Spurious glitch on the split screen + filter.onPrepareAnimation(); + KeyboardHeightFilter.FilterResult glitchResult = filter.filterOnStart(true, 1034, "1080x960|default"); + + // It should emit 400 because downward clamping is active! + // The spurious glitch of 1034 is capped back to the verified 400 height of the split screen. + assertEquals(400, glitchResult.emitHeight); + } + + @Test + public void testKeyboardSwitchGboardToSwiftKey() { + KeyboardHeightFilter filter = new KeyboardHeightFilter(); + filter.setEventMode(Keyboard.EventMode.LAST_KNOWN); + + // 1. Initial Open with SwiftKey (316) + filter.onPrepareAnimation(); + filter.filterOnApplyWindowInsets(true, 316, "1080x1920|swiftkey"); + filter.filterOnStart(true, 316, "1080x1920|swiftkey"); + filter.filterOnEnd(true, 316, "1080x1920|swiftkey"); + + // 2. Close + filter.onPrepareAnimation(); + filter.filterOnApplyWindowInsets(false, 0, "1080x1920|swiftkey"); + filter.filterOnStart(false, 0, "1080x1920|swiftkey"); + filter.filterOnEnd(false, 0, "1080x1920|swiftkey"); + + // 3. User switches to Gboard (358) and opens + // Because the map key changed to "1080x1920|gboard", it yields a cache miss! + filter.onPrepareAnimation(); + filter.filterOnApplyWindowInsets(true, 358, "1080x1920|gboard"); + KeyboardHeightFilter.FilterResult startResult = filter.filterOnStart(true, 358, "1080x1920|gboard"); + + // Assert: It MUST emit 358 because the physical keyboard has changed! + assertEquals(358, startResult.emitHeight); + } + + @Test + public void testSwiftKeyMissingSuggestionBar() { + KeyboardHeightFilter filter = new KeyboardHeightFilter(); + filter.setEventMode(Keyboard.EventMode.LAST_KNOWN); + + // 1. Initial stable open (316) + filter.onPrepareAnimation(); + filter.filterOnStart(true, 316, "1080x1920|swiftkey"); + filter.filterOnEnd(true, 316, "1080x1920|swiftkey"); + + // 2. Keyboard hides + filter.onPrepareAnimation(); + filter.filterOnEnd(false, 0, "1080x1920|swiftkey"); + + // 3. Keyboard opens again, but OS reports missing suggestion bar (272) + filter.onPrepareAnimation(); + KeyboardHeightFilter.FilterResult startResult = filter.filterOnStart(true, 272, "1080x1920|swiftkey"); + + // Assert: It MUST clamp upwards to 316 to prevent the webview from crushing! + assertEquals(316, startResult.emitHeight); + } + + @Test + public void testPhantomRotationGlitch() { + KeyboardHeightFilter filter = new KeyboardHeightFilter(); + filter.setEventMode(Keyboard.EventMode.LAST_KNOWN); + + // 1. Phantom OS layout pass with garbage bounds (650) outside of an animation + filter.filterOnApplyWindowInsets(true, 650, "1080x1920|default"); + filter.filterOnApplyWindowInsets(false, 0, "1080x1920|default"); + + // 2. User opens the keyboard for real (369) + filter.onPrepareAnimation(); + KeyboardHeightFilter.FilterResult startResult = filter.filterOnStart(true, 369, "1080x1920|default"); + + // Assert: It MUST emit 369 because the phantom 650 should NOT have poisoned the cache! + assertEquals(369, startResult.emitHeight); + } + + @Test + public void testEdgeToEdgeDetection() { + // 1. Lincoln's App (navigationBarInsets = 'ignore') + // If the developer explicitly ignores the insets, it ALWAYS returns true (edge-to-edge), regardless of SDK version. + assertTrue(Keyboard.isWindowEdgeToEdge("ignore", 33, 33)); + assertTrue(Keyboard.isWindowEdgeToEdge("ignore", 35, 35)); + + // 2. Explicit 'subtract' App + // The developer explicitly forces subtraction. + assertFalse(Keyboard.isWindowEdgeToEdge("subtract", 36, 36)); + + // 3. Standard Capacitor App on Android 13 (SDK 33) + // The developer has omitted the config, defaulting to "auto". + // Since it's older than API 35, it is NOT forced into edge-to-edge. + assertFalse(Keyboard.isWindowEdgeToEdge("auto", 33, 33)); + + // 4. Standard Capacitor App on Android 16 (SDK 36) + // The developer has omitted the config, defaulting to "auto". + // Since both the device OS and the target SDK are >= 35, it IS forced into edge-to-edge. + assertTrue(Keyboard.isWindowEdgeToEdge("auto", 36, 35)); + + // 5. Legacy App on Android 16 (SDK 36, Target 33) + // If an old app is installed on a new device, it is NOT forced into edge-to-edge. + assertFalse(Keyboard.isWindowEdgeToEdge("auto", 36, 33)); + } } From 961ab077e750bc57d722b0419422a8d8b737d0c6 Mon Sep 17 00:00:00 2001 From: "Lincoln Baxter, III" Date: Fri, 28 Aug 2026 20:07:25 -0400 Subject: [PATCH 7/7] feat(android): add Edge-to-Edge detection and navigationBarInsets config Automatically disables nav bar subtraction on Android 15+ devices where Edge-to-Edge is enforced by the OS. Exposes a new 'navigationBarInsets' config option to allow developers to manually override this behavior. --- .../plugins/keyboard/Keyboard.java | 67 +++++++++++++++---- .../plugins/keyboard/KeyboardPlugin.java | 4 +- src/definitions.ts | 11 +-- 3 files changed, 61 insertions(+), 21 deletions(-) diff --git a/android/src/main/java/com/capacitorjs/plugins/keyboard/Keyboard.java b/android/src/main/java/com/capacitorjs/plugins/keyboard/Keyboard.java index bd750e3..08cd257 100644 --- a/android/src/main/java/com/capacitorjs/plugins/keyboard/Keyboard.java +++ b/android/src/main/java/com/capacitorjs/plugins/keyboard/Keyboard.java @@ -40,8 +40,8 @@ public enum EventMode { private final KeyboardHeightFilter filter = new KeyboardHeightFilter(); - public void setSubtractNavigationBar(boolean subtractNavigationBar) { - this.subtractNavigationBar = subtractNavigationBar; + public void setNavigationBarInsets(String navigationBarInsets) { + this.navigationBarInsets = navigationBarInsets; } public void setEventMode(String modeStr) { @@ -61,7 +61,7 @@ public void setKeyboardEventListener(@Nullable KeyboardEventListener keyboardEve @Nullable private KeyboardEventListener keyboardEventListener; - private boolean subtractNavigationBar = true; + private String navigationBarInsets = "auto"; static final String EVENT_KB_WILL_SHOW = "keyboardWillShow"; static final String EVENT_KB_DID_SHOW = "keyboardDidShow"; @@ -74,6 +74,37 @@ public Keyboard(Bridge bridge, boolean resizeOnFullScreen) { this.bridge = bridge; } + // We may want to deprecate this constructor in the future, but we are keeping it now to keep backward compatibility with cap 7 + private String getKeyboardId() { + if (activity == null) return "unknown"; + try { + return android.provider.Settings.Secure.getString(activity.getContentResolver(), android.provider.Settings.Secure.DEFAULT_INPUT_METHOD); + } catch (Exception e) { + return "unknown"; + } + } + + static boolean isWindowEdgeToEdge(String navigationBarInsets, int sdkInt, int targetSdk) { + if ("ignore".equalsIgnoreCase(navigationBarInsets)) { + return true; // Never subtract (app always draws behind nav bar) + } else if ("subtract".equalsIgnoreCase(navigationBarInsets)) { + return false; // Always subtract (app does not draw behind nav bar) + } + + // "auto" (default) + // Starting in Android 15 (API 35), apps targeting SDK 35+ are forced into Edge-To-Edge by the OS. + // We dynamically detect this so a single APK works perfectly on both Android 13 (subtracted) and Android 16 (forced edge-to-edge). + if (sdkInt >= 35 && targetSdk >= 35) { + return true; + } + return false; + } + + private boolean isWindowEdgeToEdge() { + int targetSdk = activity != null ? activity.getApplicationInfo().targetSdkVersion : 0; + return isWindowEdgeToEdge(navigationBarInsets, Build.VERSION.SDK_INT, targetSdk); + } + // We may want to deprecate this constructor in the future, but we are keeping it now to keep backward compatibility with cap 7 public Keyboard(AppCompatActivity activity, boolean resizeOnFullScreen) { this.activity = activity; @@ -87,16 +118,17 @@ public Keyboard(AppCompatActivity activity, boolean resizeOnFullScreen) { if (rootInsets == null) return insets; boolean showingKeyboard = rootInsets.isVisible(WindowInsetsCompat.Type.ime()); - int rawImeHeight = rootInsets.getInsets(WindowInsetsCompat.Type.ime()).bottom; - int navBarHeight = rootInsets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom; - boolean ignoreNavBar = resizeOnFullScreen || !subtractNavigationBar; + int rawImeHeight = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom; + int navBarHeight = insets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom; + boolean ignoreNavBar = resizeOnFullScreen || isWindowEdgeToEdge(); int imeHeight = filter.calculateImeHeight(rawImeHeight, navBarHeight, ignoreNavBar); DisplayMetrics dm = activity.getResources().getDisplayMetrics(); final float density = dm.density; int currentImeHeight = Math.round(imeHeight / density); - KeyboardHeightFilter.FilterResult result = filter.filterOnApplyWindowInsets(showingKeyboard, currentImeHeight, dm.widthPixels); - Log.i("Capacitor/Keyboard", "onApplyWindowInsets: showing=" + showingKeyboard + " rawHeight=" + currentImeHeight + " emit=" + result.emitHeight + " shouldEmit=" + result.shouldEmit); + String screenKey = getScreenKey(); + KeyboardHeightFilter.FilterResult result = filter.filterOnApplyWindowInsets(showingKeyboard, currentImeHeight, screenKey); + Log.i("Capacitor/Keyboard", "onApplyWindowInsets: showing=" + showingKeyboard + " rawHeight=" + currentImeHeight + " emit=" + result.emitHeight + " shouldEmit=" + result.shouldEmit + " key=" + screenKey); if (result.shouldEmit && keyboardEventListener != null) { if (showingKeyboard) { @@ -159,7 +191,7 @@ public WindowInsetsAnimationCompat.BoundsCompat onStart( boolean showingKeyboard = insets.isVisible(WindowInsetsCompat.Type.ime()); int rawImeHeight = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom; int navBarHeight = insets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom; - boolean ignoreNavBar = resizeOnFullScreen || !subtractNavigationBar; + boolean ignoreNavBar = resizeOnFullScreen || isWindowEdgeToEdge(); int imeHeight = filter.calculateImeHeight(rawImeHeight, navBarHeight, ignoreNavBar); DisplayMetrics dm = activity.getResources().getDisplayMetrics(); final float density = dm.density; @@ -169,8 +201,9 @@ public WindowInsetsAnimationCompat.BoundsCompat onStart( } int currentImeHeight = Math.round(imeHeight / density); - KeyboardHeightFilter.FilterResult result = filter.filterOnStart(showingKeyboard, currentImeHeight, dm.widthPixels); - Log.i("Capacitor/Keyboard", "onStart: showing=" + showingKeyboard + " rawHeight=" + currentImeHeight + " emit=" + result.emitHeight + " shouldEmit=" + result.shouldEmit); + String screenKey = getScreenKey(); + KeyboardHeightFilter.FilterResult result = filter.filterOnStart(showingKeyboard, currentImeHeight, screenKey); + Log.i("Capacitor/Keyboard", "onStart: showing=" + showingKeyboard + " rawHeight=" + currentImeHeight + " emit=" + result.emitHeight + " shouldEmit=" + result.shouldEmit + " key=" + screenKey); if (result.shouldEmit && keyboardEventListener != null) { if (showingKeyboard) { @@ -192,14 +225,15 @@ public void onEnd(@NonNull WindowInsetsAnimationCompat animation) { boolean showingKeyboard = insets.isVisible(WindowInsetsCompat.Type.ime()); int rawImeHeight = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom; int navBarHeight = insets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom; - boolean ignoreNavBar = resizeOnFullScreen || !subtractNavigationBar; + boolean ignoreNavBar = resizeOnFullScreen || isWindowEdgeToEdge(); int imeHeight = filter.calculateImeHeight(rawImeHeight, navBarHeight, ignoreNavBar); DisplayMetrics dm = activity.getResources().getDisplayMetrics(); final float density = dm.density; int currentImeHeight = Math.round(imeHeight / density); - KeyboardHeightFilter.FilterResult result = filter.filterOnEnd(showingKeyboard, currentImeHeight, dm.widthPixels); - Log.i("Capacitor/Keyboard", "onEnd: showing=" + showingKeyboard + " rawHeight=" + currentImeHeight + " emit=" + result.emitHeight + " shouldEmit=" + result.shouldEmit); + String screenKey = getScreenKey(); + KeyboardHeightFilter.FilterResult result = filter.filterOnEnd(showingKeyboard, currentImeHeight, screenKey); + Log.i("Capacitor/Keyboard", "onEnd: showing=" + showingKeyboard + " rawHeight=" + currentImeHeight + " emit=" + result.emitHeight + " shouldEmit=" + result.shouldEmit + " key=" + screenKey); if (result.shouldEmit && keyboardEventListener != null) { if (showingKeyboard) { @@ -245,6 +279,11 @@ private void possiblyResizeChildOfContent(boolean keyboardShown) { } } + private String getScreenKey() { + DisplayMetrics dm = activity.getResources().getDisplayMetrics(); + return dm.widthPixels + "x" + dm.heightPixels + "|" + getKeyboardId(); + } + private int computeUsableHeight() { Rect r = new Rect(); mChildOfContent.getWindowVisibleDisplayFrame(r); diff --git a/android/src/main/java/com/capacitorjs/plugins/keyboard/KeyboardPlugin.java b/android/src/main/java/com/capacitorjs/plugins/keyboard/KeyboardPlugin.java index 82639ff..79f5fa9 100644 --- a/android/src/main/java/com/capacitorjs/plugins/keyboard/KeyboardPlugin.java +++ b/android/src/main/java/com/capacitorjs/plugins/keyboard/KeyboardPlugin.java @@ -17,11 +17,11 @@ public class KeyboardPlugin extends Plugin { public void load() { execute(() -> { boolean resizeOnFullScreen = getConfig().getBoolean("resizeOnFullScreen", false); - boolean subtractNavigationBar = getConfig().getBoolean("subtractNavigationBar", true); + String navigationBarInsets = getConfig().getString("navigationBarInsets", "auto"); String eventMode = getConfig().getString("eventMode", "DEFAULT"); implementation = new Keyboard(getBridge(), resizeOnFullScreen); implementation.setEventMode(eventMode); - implementation.setSubtractNavigationBar(subtractNavigationBar); + implementation.setNavigationBarInsets(navigationBarInsets); implementation.setKeyboardEventListener(this::onKeyboardEvent); }); diff --git a/src/definitions.ts b/src/definitions.ts index e5cbe2b..02752b8 100644 --- a/src/definitions.ts +++ b/src/definitions.ts @@ -53,16 +53,17 @@ declare module '@capacitor/cli' { eventMode?: KeyboardEventMode; /** - * If true (default), the plugin will automatically subtract the Android Navigation Bar height - * from the reported keyboard height when resizeOnFullScreen is false. - * Set to false to disable this subtraction if you are manually handling Edge-to-Edge insets. + * How the plugin handles the Android Navigation Bar height when calculating the keyboard's intersection with the WebView. + * - 'auto': (Default) Subtracts the nav bar on Android 14 and below. Automatically ignores it on Android 15+ where Edge-to-Edge is enforced by the OS. + * - 'subtract': Always subtract the nav bar height (use if your app does NOT draw behind the nav bar). + * - 'ignore': Never subtract the nav bar height (use if your app ALWAYS draws behind the nav bar). * * Only available for Android * * @since 8.0.5 - * @example false + * @example "auto" */ - subtractNavigationBar?: boolean; + navigationBarInsets?: 'auto' | 'subtract' | 'ignore'; /** * Controls how the keyboard backdrop color (the area visible behind the