diff --git a/README.md b/README.md index d7322f4..181073a 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,7 @@ the following events also work with `window.addEventListener`: * [`show()`](#show) +* [`setInputContext(...)`](#setinputcontext) * [`hide()`](#hide) * [`setAccessoryBarVisible(...)`](#setaccessorybarvisible) * [`setScroll(...)`](#setscroll) @@ -131,6 +132,22 @@ This method is only supported on Android. -------------------- +### setInputContext(...) + +```typescript +setInputContext(options: { context: string; }) => Promise +``` + +Internal method used to inform the native plugin of the currently focused input context. +Do not call this directly; it is managed automatically. + +| Param | Type | +| ------------- | --------------------------------- | +| **`options`** | { context: string; } | + +-------------------- + + ### hide() ```typescript 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..2e51b69 100644 --- a/android/src/main/java/com/capacitorjs/plugins/keyboard/Keyboard.java +++ b/android/src/main/java/com/capacitorjs/plugins/keyboard/Keyboard.java @@ -4,6 +4,7 @@ import android.graphics.Rect; import android.os.Build; import android.util.DisplayMetrics; +import android.util.Log; import android.util.TypedValue; import android.view.View; import android.view.Window; @@ -28,10 +29,71 @@ interface KeyboardEventListener { private Bridge bridge; private AppCompatActivity activity; private View rootView; + private boolean resizeOnFullScreen; private int usableHeightPrevious; private FrameLayout.LayoutParams frameLayoutParams; private View mChildOfContent; + public enum EventMode { + DEFAULT, + LAST_KNOWN + } + + private final KeyboardHeightFilter filter = new KeyboardHeightFilter(); + // Delay before re-checking a clamped, non-animated IME height change (e.g. type-mode switch). + private static final long PENDING_HEIGHT_CONFIRM_DELAY_MS = 0; + private Runnable pendingHeightCheck; + + private void schedulePendingHeightCheck(long delayMs, boolean resizeOnFullScreen, float density) { + if (pendingHeightCheck != null) { + rootView.removeCallbacks(pendingHeightCheck); + pendingHeightCheck = null; + } + pendingHeightCheck = () -> { + pendingHeightCheck = null; + WindowInsetsCompat nowInsets = ViewCompat.getRootWindowInsets(rootView); + if (nowInsets == null) return; + boolean nowShowing = nowInsets.isVisible(WindowInsetsCompat.Type.ime()); + int nowRaw = nowInsets.getInsets(WindowInsetsCompat.Type.ime()).bottom; + int nowNav = nowInsets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom; + int nowHeight = Math.round(filter.calculateImeHeight(nowRaw, nowNav, resizeOnFullScreen || isWindowEdgeToEdge()) / density); + String nowKey = getScreenKey(); + KeyboardHeightFilter.FilterResult confirm = filter.confirmPendingHeight(nowShowing, nowHeight, nowKey); + Log.i( + "Capacitor/Keyboard", + "confirmPendingHeight: showing=" + + nowShowing + + " rawHeight=" + + nowHeight + + " emit=" + + confirm.emitHeight + + " shouldEmit=" + + confirm.shouldEmit + + " key=" + + nowKey + ); + if (confirm.shouldEmit && keyboardEventListener != null) { + keyboardEventListener.onKeyboardEvent(EVENT_KB_WILL_SHOW, confirm.emitHeight); + keyboardEventListener.onKeyboardEvent(EVENT_KB_DID_SHOW, confirm.emitHeight); + } + }; + rootView.postDelayed(pendingHeightCheck, delayMs); + } + + public void setNavigationBarInsets(String navigationBarInsets) { + this.navigationBarInsets = navigationBarInsets; + } + + public void setEventMode(String modeStr) { + if (modeStr != null) { + try { + filter.setEventMode(EventMode.valueOf(modeStr.toUpperCase())); + } catch (IllegalArgumentException e) { + filter.setEventMode(EventMode.DEFAULT); + } + } + } + public void setKeyboardEventListener(@Nullable KeyboardEventListener keyboardEventListener) { this.keyboardEventListener = keyboardEventListener; } @@ -39,6 +101,54 @@ public void setKeyboardEventListener(@Nullable KeyboardEventListener keyboardEve @Nullable private KeyboardEventListener keyboardEventListener; + private String navigationBarInsets = "auto"; + // Input context of the focused web element (inputmode/type), reported by the web side. + private volatile String inputContext = ""; + + public void setInputContext(String inputContext) { + String value = inputContext == null ? "" : inputContext; + boolean changed = !value.equals(this.inputContext); + Log.i("Capacitor/Keyboard", "setInputContext: " + value + (changed ? "" : " (unchanged)")); + this.inputContext = value; + if (changed && rootView != null) { + // Insets must be read on the UI thread; the plugin call arrives on the bridge executor. + rootView.post(this::reevaluateAfterInputContextChange); + } + } + + /** + * If a show animation is already running, onStart clamped its height against the previous + * input context's cache. Re-key with the new context and correct keyboardWillShow if needed. + */ + private void reevaluateAfterInputContextChange() { + WindowInsetsCompat insets = ViewCompat.getRootWindowInsets(rootView); + if (insets == null) return; + 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 || isWindowEdgeToEdge(); + float density = activity.getResources().getDisplayMetrics().density; + int currentImeHeight = Math.round(filter.calculateImeHeight(rawImeHeight, navBarHeight, ignoreNavBar) / density); + String screenKey = getScreenKey(); + KeyboardHeightFilter.FilterResult result = filter.filterOnInputContextChanged(showingKeyboard, currentImeHeight, screenKey); + Log.i( + "Capacitor/Keyboard", + "onInputContextChanged: showing=" + + showingKeyboard + + " rawHeight=" + + currentImeHeight + + " emit=" + + result.emitHeight + + " shouldEmit=" + + result.shouldEmit + + " key=" + + screenKey + ); + if (result.shouldEmit && keyboardEventListener != null) { + keyboardEventListener.onKeyboardEvent(EVENT_KB_WILL_SHOW, result.emitHeight); + } + } + static final String EVENT_KB_WILL_SHOW = "keyboardWillShow"; static final String EVENT_KB_DID_SHOW = "keyboardDidShow"; static final String EVENT_KB_WILL_HIDE = "keyboardWillHide"; @@ -50,9 +160,44 @@ 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; + this.resizeOnFullScreen = resizeOnFullScreen; //http://stackoverflow.com/a/4737265/1091751 detect if keyboard is showing FrameLayout content = activity.getWindow().getDecorView().findViewById(android.R.id.content); @@ -63,25 +208,86 @@ public Keyboard(AppCompatActivity activity, boolean resizeOnFullScreen) { if (rootInsets == null) return insets; boolean showingKeyboard = rootInsets.isVisible(WindowInsetsCompat.Type.ime()); + 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); + 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 + + " pending=" + + result.pendingHeight + + " key=" + + screenKey + ); + + if (result.pendingHeight > 0) { + schedulePendingHeightCheck(PENDING_HEIGHT_CONFIRM_DELAY_MS, resizeOnFullScreen, density); + } else if (pendingHeightCheck != null && (!showingKeyboard || result.shouldEmit)) { + rootView.removeCallbacks(pendingHeightCheck); + pendingHeightCheck = null; + } + + 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); + } + } - if (resizeOnFullScreen) { - possiblyResizeChildOfContent(showingKeyboard); + 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) { + Log.i("Capacitor/Keyboard", "onPrepareAnimation"); + filter.onPrepareAnimation(); + 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; } @@ -94,7 +300,10 @@ 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; + boolean ignoreNavBar = resizeOnFullScreen || isWindowEdgeToEdge(); + int imeHeight = filter.calculateImeHeight(rawImeHeight, navBarHeight, ignoreNavBar); DisplayMetrics dm = activity.getResources().getDisplayMetrics(); final float density = dm.density; @@ -102,28 +311,71 @@ public WindowInsetsAnimationCompat.BoundsCompat onStart( possiblyResizeChildOfContent(showingKeyboard); } - if (showingKeyboard) { - keyboardEventListener.onKeyboardEvent(EVENT_KB_WILL_SHOW, Math.round(imeHeight / density)); - } else { - keyboardEventListener.onKeyboardEvent(EVENT_KB_WILL_HIDE, 0); + int currentImeHeight = Math.round(imeHeight / density); + 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) { + keyboardEventListener.onKeyboardEvent(EVENT_KB_WILL_SHOW, result.emitHeight); + } else { + keyboardEventListener.onKeyboardEvent(EVENT_KB_WILL_HIDE, 0); + } } + return super.onStart(animation, bounds); } @Override public void onEnd(@NonNull WindowInsetsAnimationCompat animation) { super.onEnd(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; + boolean ignoreNavBar = resizeOnFullScreen || isWindowEdgeToEdge(); + int imeHeight = filter.calculateImeHeight(rawImeHeight, navBarHeight, ignoreNavBar); DisplayMetrics dm = activity.getResources().getDisplayMetrics(); final float density = dm.density; - if (showingKeyboard) { - keyboardEventListener.onKeyboardEvent(EVENT_KB_DID_SHOW, Math.round(imeHeight / density)); - } else { - keyboardEventListener.onKeyboardEvent(EVENT_KB_DID_HIDE, 0); + int currentImeHeight = Math.round(imeHeight / density); + 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) { + keyboardEventListener.onKeyboardEvent(EVENT_KB_DID_SHOW, result.emitHeight); + } else { + keyboardEventListener.onKeyboardEvent(EVENT_KB_DID_HIDE, 0); + } } } } @@ -162,6 +414,15 @@ private void possiblyResizeChildOfContent(boolean keyboardShown) { } } + private String getScreenKey() { + DisplayMetrics dm = activity.getResources().getDisplayMetrics(); + String key = dm.widthPixels + "x" + dm.heightPixels + "|" + getKeyboardId(); + if (!inputContext.isEmpty()) { + key += "|" + inputContext; + } + return key; + } + private int computeUsableHeight() { Rect r = new Rect(); mChildOfContent.getWindowVisibleDisplayFrame(r); 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..533c4b4 --- /dev/null +++ b/android/src/main/java/com/capacitorjs/plugins/keyboard/KeyboardHeightFilter.java @@ -0,0 +1,224 @@ +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; + private boolean justEndedAnimation = false; + private String lastAnimationScreenKey = ""; + // Non-animated height change (e.g. IME type-mode switch) awaiting confirmation that it persists. + private int pendingHeight = 0; + private String pendingScreenKey = ""; + + 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; + } + + public static class FilterResult { + + public final int emitHeight; + public final boolean shouldEmit; + // When > 0, the caller should re-check the IME height after a short delay and call confirmPendingHeight(). + public final int pendingHeight; + + public FilterResult(int emitHeight, boolean shouldEmit) { + this(emitHeight, shouldEmit, 0); + } + + public FilterResult(int emitHeight, boolean shouldEmit, int pendingHeight) { + this.emitHeight = emitHeight; + this.shouldEmit = shouldEmit; + this.pendingHeight = pendingHeight; + } + } + + public void onPrepareAnimation() { + this.isAnimating = true; + clearPending(); + } + + private void clearPending() { + this.pendingHeight = 0; + this.pendingScreenKey = ""; + } + + /** + * Called after a short delay with the height the OS still reports. If it matches the pending + * (previously clamped) height, the change was real (e.g. IME switched type mode without an + * animation) and we promote it to the verified cache and emit. + */ + public FilterResult confirmPendingHeight(boolean showingKeyboard, int currentImeHeight, String screenKey) { + int pending = pendingHeight; + String pendingKey = pendingScreenKey; + clearPending(); + if (pending <= 0 || isAnimating || !showingKeyboard) { + return new FilterResult(0, false); + } + if (currentImeHeight != pending || !screenKey.equals(pendingKey)) { + // Height moved on again or keyboard changed: it really was transient. + return new FilterResult(currentImeHeight, false); + } + knownHeights.put(screenKey, new VerifiedHeight(pending, true)); + boolean shouldEmit = pending != lastImeHeight; + lastImeHeight = pending; + return new FilterResult(pending, shouldEmit); + } + + public FilterResult filterOnApplyWindowInsets(boolean showingKeyboard, int currentImeHeight, String screenKey) { + if (isAnimating) { + return new FilterResult(0, false); + } + + if (!showingKeyboard) { + lastImeHeight = 0; + clearPending(); + return new FilterResult(0, false); + } + + 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; + int pending = 0; + if (!isVerified && currentImeHeight != emitHeight) { + // We just clamped an unverified height. It is either a transient OS glitch (phantom + // rotation, aborted animation) or a real non-animated change such as the IME switching + // type mode (text -> numeric). Do not emit now; ask the caller to re-check shortly. + shouldEmit = false; + if (currentImeHeight > 0) { + pending = currentImeHeight; + pendingHeight = currentImeHeight; + pendingScreenKey = screenKey; + } + } else { + clearPending(); + } + + if (shouldEmit) { + lastImeHeight = emitHeight; + } + return new FilterResult(emitHeight, shouldEmit, pending); + } + + public FilterResult filterOnStart(boolean showingKeyboard, int currentImeHeight, String screenKey) { + this.isAnimating = true; + this.justEndedAnimation = false; + clearPending(); + + if (!showingKeyboard) { + lastImeHeight = 0; + return new FilterResult(0, true); + } + + int emitHeight = currentImeHeight; + VerifiedHeight known = knownHeights.get(screenKey); + + // 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; + if (shouldEmit) { + lastImeHeight = emitHeight; + } + return new FilterResult(emitHeight, shouldEmit); + } + + /** + * The web side reported a new input context after a show animation had already started under + * the previous context's key. This happens when focus moves between inputs while the IME is + * open: the OS runs hide -> show back to back and the bridge call carrying the new context + * loses the race against onStart, so onStart clamped to the OLD context's verified height. + * Re-evaluate for the new key and let the caller re-emit WILL_SHOW if the height differs. + */ + public FilterResult filterOnInputContextChanged(boolean showingKeyboard, int currentImeHeight, String screenKey) { + if (!isAnimating || !showingKeyboard) { + return new FilterResult(0, false); + } + int emitHeight = currentImeHeight; + VerifiedHeight known = knownHeights.get(screenKey); + if (eventMode == Keyboard.EventMode.LAST_KNOWN && known != null && known.isVerified && known.height > 0) { + emitHeight = known.height; + } + boolean shouldEmit = emitHeight > 0 && emitHeight != lastImeHeight; + if (shouldEmit) { + lastImeHeight = emitHeight; + } + return new FilterResult(emitHeight, shouldEmit); + } + + 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; + lastImeHeight = emitHeight; + + // 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 (currentImeHeight > 0) { + knownHeights.put(screenKey, new VerifiedHeight(currentImeHeight, true)); + } + } + + // The DID_SHOW event must unconditionally fire when the animation finishes + return new FilterResult(emitHeight, true); + } + + 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 40fe0b7..47bd301 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,11 @@ public class KeyboardPlugin extends Plugin { public void load() { execute(() -> { boolean resizeOnFullScreen = getConfig().getBoolean("resizeOnFullScreen", false); + String navigationBarInsets = getConfig().getString("navigationBarInsets", "auto"); + String eventMode = getConfig().getString("eventMode", "DEFAULT"); implementation = new Keyboard(getBridge(), resizeOnFullScreen); + implementation.setEventMode(eventMode); + implementation.setNavigationBarInsets(navigationBarInsets); implementation.setKeyboardEventListener(this::onKeyboardEvent); }); @@ -44,6 +48,21 @@ public void hide(final PluginCall call) { }); } + /** + * Web side reports the focused element's input context (e.g. "text", "numeric", "email"). + * IMEs use different layouts (and heights) per input type, so the height cache is keyed by it. + */ + @PluginMethod + public void setInputContext(PluginCall call) { + String context = call.getString("context", ""); + execute(() -> { + if (implementation != null) { + implementation.setInputContext(context); + } + call.resolve(); + }); + } + @PluginMethod public void setAccessoryBarVisible(PluginCall call) { call.unimplemented(); 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..735fb8e --- /dev/null +++ b/android/src/test/java/com/capacitorjs/plugins/keyboard/KeyboardHeightFilterTest.java @@ -0,0 +1,556 @@ +package com.capacitorjs.plugins.keyboard; + +import static org.junit.Assert.*; + +import org.junit.Test; + +public class KeyboardHeightFilterTest { + + private static final String PORTRAIT_KEY = "1080x1920|default"; + private static final String LANDSCAPE_KEY = "1920x1080|default"; + + @Test + public void testNormalOpen() { + KeyboardHeightFilter filter = new KeyboardHeightFilter(); + filter.setEventMode(Keyboard.EventMode.LAST_KNOWN); + + filter.onPrepareAnimation(); + + KeyboardHeightFilter.FilterResult startResult = filter.filterOnStart(true, 941, PORTRAIT_KEY); + assertTrue(startResult.shouldEmit); + assertEquals(941, startResult.emitHeight); + + 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_KEY); + 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_KEY); + + filter.onPrepareAnimation(); + + // Close animation starts + 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_KEY); + assertTrue(endResult.shouldEmit); + assertEquals(0, endResult.emitHeight); + } + + @Test + public void testRapidAbortIsCapped() { + KeyboardHeightFilter filter = new KeyboardHeightFilter(); + filter.setEventMode(Keyboard.EventMode.LAST_KNOWN); + + // 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_KEY); + assertFalse(earlyApply.shouldEmit); // Suppressed because isAnimating is true! + + // Rapid abort sends spurious 1034px onStart + KeyboardHeightFilter.FilterResult startResult = filter.filterOnStart(true, 1034, PORTRAIT_KEY); + + // 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 1034px + KeyboardHeightFilter.FilterResult endResult = filter.filterOnEnd(true, 1034, PORTRAIT_KEY); + assertTrue(endResult.shouldEmit); + assertEquals(1034, endResult.emitHeight); + } + + @Test + public void testSuggestionBarLegitimateGrowth() { + KeyboardHeightFilter filter = new KeyboardHeightFilter(); + filter.setEventMode(Keyboard.EventMode.LAST_KNOWN); + + // Establish resting height + 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_KEY); + + // 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_KEY); // emitHeight = 941, lastImeHeight = 941 + + // onEnd fires with the EXACT same height + KeyboardHeightFilter.FilterResult endResult = filter.filterOnEnd(true, 941, PORTRAIT_KEY); + + // 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_KEY); + + // OS hides keyboard abruptly outside of an animation + 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); + } + + @Test + public void testDefaultModeDoesNotCapHeights() { + KeyboardHeightFilter filter = new KeyboardHeightFilter(); + filter.setEventMode(Keyboard.EventMode.DEFAULT); + + // Establish resting height + 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_KEY); + + // 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_KEY); + filter.filterOnEnd(true, 369, PORTRAIT_KEY); + + // 2. Rapid Abort close + filter.onPrepareAnimation(); + 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_KEY); + + // It MUST cap downwards to the verified ceiling of 369. + assertTrue(startResult.shouldEmit); // 369 != 0 + 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_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_KEY); + + // 3. Open in Portrait (tall) + filter.onPrepareAnimation(); + 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_KEY key. + assertTrue(startResult.shouldEmit); + assertEquals(369, startResult.emitHeight); + + // 4. Rotate BACK to landscape + filter.filterOnApplyWindowInsets(false, 0, LANDSCAPE_KEY); + + // 5. Glitched open in landscape (taller than 224) + filter.onPrepareAnimation(); + KeyboardHeightFilter.FilterResult glitchResult = filter.filterOnStart(true, 300, LANDSCAPE_KEY); + + // Downward clamping is active! It should emit 224! + assertTrue(glitchResult.shouldEmit); // 224 != 0 + 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_KEY); + filter.filterOnEnd(true, 369, PORTRAIT_KEY); + + // 2. Rotate to Landscape (transitional layout pass showing=true, height=0) + filter.filterOnApplyWindowInsets(true, 0, LANDSCAPE_KEY); + + // 3. Open Landscape + filter.onPrepareAnimation(); + 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). + assertEquals(224, startResult.emitHeight); + 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(); + filter.setEventMode(Keyboard.EventMode.LAST_KNOWN); + + // 1. Establish Portrait + filter.onPrepareAnimation(); + 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_KEY); + + // 3. Open Portrait again + filter.onPrepareAnimation(); + KeyboardHeightFilter.FilterResult startResult = filter.filterOnStart(true, 369, PORTRAIT_KEY); + + // 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); + } + + @Test + public void testCalculateImeHeightAccountsForNavBar() { + KeyboardHeightFilter filter = new KeyboardHeightFilter(); + + // 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 (ignoreNavBar = 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)); + } + + @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)); + } + + @Test + public void testDynamicInputContextCaching() { + KeyboardHeightFilter filter = new KeyboardHeightFilter(); + filter.setEventMode(Keyboard.EventMode.LAST_KNOWN); + + // 1. Initial stable open for TEXT input (316) + filter.onPrepareAnimation(); + filter.filterOnStart(true, 316, "1080x1920|swiftkey|text"); + filter.filterOnEnd(true, 316, "1080x1920|swiftkey|text"); + + // 2. Keyboard hides + filter.onPrepareAnimation(); + filter.filterOnEnd(false, 0, "1080x1920|swiftkey|text"); + + // 3. User focuses NUMERIC input (272) + // Since the context is 'numeric', this gets an isolated cache bucket! + filter.onPrepareAnimation(); + KeyboardHeightFilter.FilterResult startResult = filter.filterOnStart(true, 272, "1080x1920|swiftkey|numeric"); + + // Assert: It MUST emit 272 and NOT clamp upwards to 316! + assertEquals(272, startResult.emitHeight); + } + + @Test + public void testConfirmPendingHeightForTypeSwitch() { + KeyboardHeightFilter filter = new KeyboardHeightFilter(); + filter.setEventMode(Keyboard.EventMode.LAST_KNOWN); + + // 1. Stable TEXT input (316) + filter.onPrepareAnimation(); + filter.filterOnStart(true, 316, "1080x1920|swiftkey"); + filter.filterOnEnd(true, 316, "1080x1920|swiftkey"); + + // Consume the immediate post-animation static layout pass (which is normal OS behavior) + filter.filterOnApplyWindowInsets(true, 316, "1080x1920|swiftkey"); + + // 2. User manually dismisses the suggestion bar, OR developer didn't use setInputContext + // and switched inputs. OS resizes instantly (no animation) to 272. + KeyboardHeightFilter.FilterResult staticResult = filter.filterOnApplyWindowInsets(true, 272, "1080x1920|swiftkey"); + + // Because the cache has 316, it aggressively clamps it upward to 316! + // But since it clamped, it flags it as pending and suppresses emission. + assertFalse(staticResult.shouldEmit); + assertEquals(272, staticResult.pendingHeight); + + // 3. The 0ms delay Runnable fires and confirms the height is STILL 272. + KeyboardHeightFilter.FilterResult confirmResult = filter.confirmPendingHeight(true, 272, "1080x1920|swiftkey"); + + // Assert: It MUST confirm the 272 height, overwrite the 316 cache, and emit it! + assertTrue(confirmResult.shouldEmit); + assertEquals(272, confirmResult.emitHeight); + } + + @Test + public void testReevaluateAfterInputContextChange() { + KeyboardHeightFilter filter = new KeyboardHeightFilter(); + filter.setEventMode(Keyboard.EventMode.LAST_KNOWN); + + // 1. Stable TEXT input (316) and stable NUMERIC input (272) both cached + filter.onPrepareAnimation(); + filter.filterOnStart(true, 316, "1080x1920|swiftkey|text"); + filter.filterOnEnd(true, 316, "1080x1920|swiftkey|text"); + filter.onPrepareAnimation(); + filter.filterOnStart(true, 272, "1080x1920|swiftkey|numeric"); + filter.filterOnEnd(true, 272, "1080x1920|swiftkey|numeric"); + filter.onPrepareAnimation(); + filter.filterOnEnd(false, 0, "1080x1920|swiftkey|numeric"); + + // 2. User focuses TEXT input, but the asynchronous JS message 'setInputContext("text")' is slow. + // OS starts animation. Java defaults to previous context ("numeric"). + filter.onPrepareAnimation(); + KeyboardHeightFilter.FilterResult startResult = filter.filterOnStart(true, 316, "1080x1920|swiftkey|numeric"); + + // Because it thinks it's a numeric keyboard, it erroneously clamps the 316 animation start to 272! + assertEquals(272, startResult.emitHeight); + + // 3. Mid-animation, the 'setInputContext("text")' message arrives! + KeyboardHeightFilter.FilterResult reevalResult = filter.filterOnInputContextChanged(true, 316, "1080x1920|swiftkey|text"); + + // Assert: It MUST recalculate using the "text" bucket and fire a corrective keyboardWillShow(316)! + assertTrue(reevalResult.shouldEmit); + assertEquals(316, reevalResult.emitHeight); + } +} diff --git a/src/definitions.ts b/src/definitions.ts index 9605871..d8b73a6 100644 --- a/src/definitions.ts +++ b/src/definitions.ts @@ -42,6 +42,29 @@ 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; + + /** + * 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 "auto" + */ + navigationBarInsets?: 'auto' | 'subtract' | 'ignore'; + /** * Controls how the keyboard backdrop color (the area visible behind the * keyboard) is set every time the keyboard is about to show. @@ -64,6 +87,8 @@ declare module '@capacitor/cli' { } } +export type KeyboardEventMode = 'DEFAULT' | 'LAST_KNOWN'; + export interface KeyboardInfo { /** * Height of the keyboard. @@ -160,6 +185,13 @@ export interface KeyboardPlugin { */ show(): Promise; + /** + * Internal method used to inform the native plugin of the currently focused input context. + * Do not call this directly; it is managed automatically. + * @internal + */ + setInputContext(options: { context: string }): Promise; + /** * Hide the keyboard. * diff --git a/src/index.ts b/src/index.ts index a20e053..e5c8aab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,70 @@ -import { registerPlugin } from '@capacitor/core'; +import { Capacitor, registerPlugin } from '@capacitor/core'; import type { KeyboardPlugin } from './definitions'; const Keyboard = registerPlugin('Keyboard'); +if (Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android') { + let lastContext: string | null = null; + + const inputContextOf = (el: HTMLElement): string | null => { + // Only care about actual text-receiving elements + const isInput = el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || el.isContentEditable; + + if (!isInput) { + return null; + } + + const inputMode = el.getAttribute('inputmode')?.toLowerCase(); + if (inputMode) { + return inputMode; + } + if (el instanceof HTMLTextAreaElement) { + return 'text'; + } + if (el instanceof HTMLInputElement) { + switch (el.type) { + case 'number': + return 'numeric'; + case 'tel': + return 'tel'; + case 'email': + return 'email'; + case 'url': + return 'url'; + case 'search': + return 'search'; + case 'password': + return 'password'; + default: + return 'text'; + } + } + return el.isContentEditable ? 'text' : null; + }; + + if (typeof document !== 'undefined') { + document.addEventListener( + 'focusin', + (event) => { + // Use composedPath() to pierce open ShadowDOM (e.g., Ionic web components) + const target = event.composedPath?.()[0] || event.target; + + if (target instanceof HTMLElement) { + const context = inputContextOf(target); + + // Strictly filter out non-inputs (buttons, links) to prevent UI glitches, + // and deduplicate bridge traffic if the context hasn't changed. + if (context !== null && context !== lastContext) { + lastContext = context; + Keyboard.setInputContext({ context }).catch(() => undefined); + } + } + }, + true, + ); + } +} + export * from './definitions'; export { Keyboard };