Skip to content
150 changes: 136 additions & 14 deletions android/src/main/java/com/capacitorjs/plugins/keyboard/Keyboard.java
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -32,12 +33,35 @@ interface KeyboardEventListener {
private FrameLayout.LayoutParams frameLayoutParams;
private View mChildOfContent;

public enum EventMode {
DEFAULT,
LAST_KNOWN
}

private final KeyboardHeightFilter filter = new KeyboardHeightFilter();

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;
}

@Nullable
private KeyboardEventListener keyboardEventListener;

private String navigationBarInsets = "auto";

static final String EVENT_KB_WILL_SHOW = "keyboardWillShow";
static final String EVENT_KB_DID_SHOW = "keyboardDidShow";
Expand All @@ -50,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;
Expand All @@ -63,25 +118,65 @@ 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;

if (resizeOnFullScreen) {
possiblyResizeChildOfContent(showingKeyboard);
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 + " key=" + screenKey);

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);
}
}

v.onApplyWindowInsets(insets.toWindowInsets());
if (showingKeyboard && resizeOnFullScreen) {
possiblyResizeChildOfContent(true);
} else if (!showingKeyboard && resizeOnFullScreen) {
possiblyResizeChildOfContent(false);
}

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<WindowInsetsAnimationCompat> runningAnimations
) {
if (!resizeOnFullScreen) {
return new WindowInsetsCompat.Builder(insets)
.setInsets(WindowInsetsCompat.Type.ime(), androidx.core.graphics.Insets.NONE)
.build();
}
return insets;
}

Expand All @@ -94,36 +189,58 @@ 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;

if (resizeOnFullScreen) {
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);
}
}
}
}
Expand Down Expand Up @@ -162,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);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
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<String, VerifiedHeight> 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;
}

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, String screenKey) {
if (isAnimating) {
return new FilterResult(0, false);
}

if (!showingKeyboard) {
lastImeHeight = 0;
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;
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, String screenKey) {
this.isAnimating = true;
this.justEndedAnimation = false;

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);
}

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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
Loading