Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
2b49827
Setting on core definitions and docs for SystemBars plugin
theproducer Sep 15, 2025
b78f69f
Renaming, adding basic docs
theproducer Sep 17, 2025
edb5288
Adding Android SystemBars basic functionality
theproducer Sep 17, 2025
3bb5784
Adding Android safe area injection
theproducer Sep 17, 2025
6ddb7b9
fmt
theproducer Sep 17, 2025
9e6b41a
Adding insets support to setting style and hiding
theproducer Sep 22, 2025
2c93ccd
Starting on overlay webview support
theproducer Sep 24, 2025
c4be18d
Cleanup, adding support for configuration
theproducer Sep 26, 2025
11c5590
Fixes to type declarations
theproducer Sep 26, 2025
07d6b3f
Updating meaning of LIGHT vs DARK
theproducer Sep 30, 2025
0698ca7
Updating style to match documentation on iOS
theproducer Sep 30, 2025
8bcd46d
Rename `enable` to `enableInsets`
theproducer Oct 3, 2025
15fab3a
Merge branch 'main' into RDMR-902
theproducer Oct 3, 2025
bf2fe33
fmt
theproducer Oct 3, 2025
bbfb49b
Removing overlay support
theproducer Oct 22, 2025
c6664d1
Adding webview version check, optimizing insets injection
theproducer Oct 22, 2025
55717fd
fmt
theproducer Oct 22, 2025
0f7d843
Merge branch 'main' into RDMR-902
theproducer Oct 22, 2025
ba72a20
Adding config option for hiding system bars
theproducer Oct 22, 2025
e6bd10b
Adding support for configurations in iOS system bars
theproducer Oct 22, 2025
929aa33
Adding support for setting iOS status bar animations
theproducer Oct 22, 2025
114367d
Making showing / hiding status bars more friendly
theproducer Oct 22, 2025
e8df7ae
naming and consistency
theproducer Oct 22, 2025
7eb6aed
documentation
theproducer Oct 23, 2025
6fb8d70
docs, fmt
theproducer Oct 23, 2025
a104cea
fmt
theproducer Oct 23, 2025
7ca256c
Removing enableInsets setting in exchange for meta viewport checking
theproducer Oct 27, 2025
21f473b
Removing Cap 7 edgeToEdgeHandler
theproducer Oct 28, 2025
be27917
Merge branch 'main' into RDMR-902
theproducer Nov 3, 2025
cd2fb6c
Adding ability to control iOS home indicator visibility
theproducer Nov 3, 2025
cf0b1f9
fmt
theproducer Nov 3, 2025
ec05a18
Readding `adjustMarginsForEdgeToEdge`, defaulted to “auto”
theproducer Nov 4, 2025
984a254
webview and viewport check fix
theproducer Nov 7, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -655,6 +655,7 @@ private void registerAllPlugins() {
this.registerPlugin(com.getcapacitor.plugin.CapacitorCookies.class);
this.registerPlugin(com.getcapacitor.plugin.WebView.class);
this.registerPlugin(com.getcapacitor.plugin.CapacitorHttp.class);
this.registerPlugin(com.getcapacitor.plugin.SystemBars.class);

for (Class<? extends Plugin> pluginClass : this.initialPlugins) {
this.registerPlugin(pluginClass);
Expand Down Expand Up @@ -1610,7 +1611,6 @@ public Bridge create() {

if (webView instanceof CapacitorWebView capacitorWebView) {
capacitorWebView.setBridge(bridge);
capacitorWebView.edgeToEdgeHandler(bridge);
}

bridge.setCordovaWebView(mockWebView);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ public class CapConfig {
private String errorPath;
private boolean zoomableWebView = false;
private boolean resolveServiceWorkerRequests = true;
private String adjustMarginsForEdgeToEdge = "disable";
private String adjustMarginsForEdgeToEdge = "auto";

// Embedded
private String startPath;
Expand Down Expand Up @@ -288,7 +288,7 @@ private void deserializeConfig(@Nullable Context context) {
webContentsDebuggingEnabled = JSONUtils.getBoolean(configJSON, "android.webContentsDebuggingEnabled", isDebug);
zoomableWebView = JSONUtils.getBoolean(configJSON, "android.zoomEnabled", JSONUtils.getBoolean(configJSON, "zoomEnabled", false));
resolveServiceWorkerRequests = JSONUtils.getBoolean(configJSON, "android.resolveServiceWorkerRequests", true);
adjustMarginsForEdgeToEdge = JSONUtils.getString(configJSON, "android.adjustMarginsForEdgeToEdge", "disable");
adjustMarginsForEdgeToEdge = JSONUtils.getString(configJSON, "android.adjustMarginsForEdgeToEdge", "auto");

String logBehavior = JSONUtils.getString(
configJSON,
Expand Down Expand Up @@ -589,7 +589,8 @@ public static class Builder {
private int minHuaweiWebViewVersion = DEFAULT_HUAWEI_WEBVIEW_VERSION;
private boolean zoomableWebView = false;
private boolean resolveServiceWorkerRequests = true;
private String adjustMarginsForEdgeToEdge = "disable";
private String adjustMarginsForEdgeToEdge = "auto";


// Embedded
private String startPath = null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,36 +54,4 @@ public boolean dispatchKeyEvent(KeyEvent event) {
}
return super.dispatchKeyEvent(event);
}

public void edgeToEdgeHandler(Bridge bridge) {
String configEdgeToEdge = bridge.getConfig().adjustMarginsForEdgeToEdge();

if (configEdgeToEdge.equals("disable")) return;

boolean autoMargins = false;
boolean forceMargins = configEdgeToEdge.equals("force");

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM && configEdgeToEdge.equals("auto")) {
TypedValue value = new TypedValue();
boolean foundOptOut = getContext().getTheme().resolveAttribute(android.R.attr.windowOptOutEdgeToEdgeEnforcement, value, true);
boolean optOutValue = value.data != 0; // value is set to -1 on true as of Android 15, so we have to do this.

autoMargins = !(foundOptOut && optOutValue);
}

if (forceMargins || autoMargins) {
ViewCompat.setOnApplyWindowInsetsListener(this, (v, windowInsets) -> {
Insets insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars() | WindowInsetsCompat.Type.displayCutout());
MarginLayoutParams mlp = (MarginLayoutParams) v.getLayoutParams();
mlp.leftMargin = insets.left;
mlp.bottomMargin = insets.bottom;
mlp.rightMargin = insets.right;
mlp.topMargin = insets.top;
v.setLayoutParams(mlp);

// Don't pass window insets to children
return WindowInsetsCompat.CONSUMED;
});
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
package com.getcapacitor.plugin;

import android.content.pm.PackageInfo;
import android.content.res.Configuration;
import android.view.View;
import android.view.Window;
import androidx.core.graphics.Insets;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowCompat;
import androidx.core.view.WindowInsetsCompat;
import androidx.core.view.WindowInsetsControllerCompat;
import androidx.webkit.WebViewCompat;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

@CapacitorPlugin
public class SystemBars extends Plugin {

static final String STYLE_LIGHT = "LIGHT";
static final String STYLE_DARK = "DARK";
static final String STYLE_DEFAULT = "DEFAULT";
static final String INSET_TOP = "TOP";
static final String INSET_BOTTOM = "BOTTOM";

static final String viewportMetaJSFunction =
"""
function capacitorSystemBarsCheckMetaViewport() {
const meta = document.querySelectorAll("meta[name=viewport]");
if (meta.length == 0) {
return false;
}
// get the last found meta viewport tag
const metaContent = meta[meta.length - 1].content;
return metaContent.includes("viewport-fit=cover");
}
capacitorSystemBarsCheckMetaViewport();
""";

@Override
public void load() {
super.load();
initSystemBars();
}

private boolean hasFixedWebView() {
PackageInfo packageInfo = WebViewCompat.getCurrentWebViewPackage(bridge.getContext());
Pattern pattern = Pattern.compile("(\\d+)");
Matcher matcher = pattern.matcher(packageInfo.versionName);

if (!matcher.find()) {
return false;
}

String majorVersionStr = matcher.group(0);
int majorVersion = Integer.parseInt(majorVersionStr);

return majorVersion >= 140;
}

private void initSystemBars() {
String style = getConfig().getString("style", STYLE_DEFAULT).toUpperCase();
boolean hidden = getConfig().getBoolean("hidden", false);

this.bridge.getWebView()
.evaluateJavascript(viewportMetaJSFunction, res -> {
boolean hasMetaViewportCover = res.equals("true");
setupSafeAreaInsets(this.hasFixedWebView(), hasMetaViewportCover);
});

getBridge()
.executeOnMainThread(() -> {
setStyle(style, "");
setHidden(hidden, "");
});
}

@PluginMethod
public void setStyle(final PluginCall call) {
String inset = call.getString("inset", "").toUpperCase(Locale.US);
String style = call.getString("style", STYLE_DEFAULT);

getBridge()
.executeOnMainThread(() -> {
setStyle(style, inset);
call.resolve();
});
}

@PluginMethod
public void show(final PluginCall call) {
String inset = call.getString("inset", "").toUpperCase(Locale.US);

getBridge()
.executeOnMainThread(() -> {
setHidden(false, inset);
call.resolve();
});
}

@PluginMethod
public void hide(final PluginCall call) {
String inset = call.getString("inset", "").toUpperCase(Locale.US);

getBridge()
.executeOnMainThread(() -> {
setHidden(true, inset);
call.resolve();
});
}

@PluginMethod
public void setAnimation(final PluginCall call) {
call.resolve();
}

private void setupSafeAreaInsets(boolean hasFixedWebView, boolean hasMetaViewportCover) {
View decorView = getActivity().getWindow().getDecorView();

ViewCompat.setOnApplyWindowInsetsListener(decorView, (v, insets) -> {
if (hasFixedWebView && hasMetaViewportCover) {
return insets;
}

Insets safeArea = insets.getInsets(WindowInsetsCompat.Type.systemBars() | WindowInsetsCompat.Type.displayCutout());
injectSafeAreaCSS(safeArea.top, safeArea.right, safeArea.bottom, safeArea.left);
Comment on lines +130 to +131
Copy link

Choose a reason for hiding this comment

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

I think in this case we should fallback to logic similar as to what adjustMarginsForEdgeToEdge does.

Also injecting the safe area insets as custom vars should be a toggleable option I think. And only respected if hasMetaViewportCover==true imo, so it stays consistent with the native inset behavior

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think in this case we should fallback to logic similar as to what adjustMarginsForEdgeToEdge does.

You are referring to adding margins directly to the native view? That was meant to be a quick and dirty fix - we generally try to avoid manipulating the native views style-wise as much as possible to leave the power and final decision making in the hands of the web developer.

Using CSS env vars (albeit a bit convoluted because of the Android bug: var(--safe-area-inset-top, env(safe-area-inset-top, 0px));) would be the normal way of handling edge to edge behavior, and we want to stick as close to that as possible. Additionally, we have other requirements depending on these injected vars.

Also injecting the safe area insets as custom vars should be a toggleable option I think.

This is something we can do, I'll work on adding a config option for disabling the inset injections altogether.

Copy link

Choose a reason for hiding this comment

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

You are referring to adding margins directly to the native view

Yes that's what I meant. It would align with iOS behavior. If a user does not enable edge-to-edge support through the viewport-fit=cover tag, the webview itself will already add margins for you. Otherwise every capacitor developer would need to add support for env variables. Which would be a major breaking change.

Ideally, of course, this is something that the Android team will just natively support out of the box. But iirc a few years/months ago one of the devs mentioned that it's not something they were looking into. I cannot find the comment anymore. I'll link it if I come across it


return WindowInsetsCompat.CONSUMED;
});
}

private void injectSafeAreaCSS(int top, int right, int bottom, int left) {
// Convert pixels to density-independent pixels
float density = getActivity().getResources().getDisplayMetrics().density;
float topPx = top / density;
float rightPx = right / density;
float bottomPx = bottom / density;
float leftPx = left / density;

// Execute JavaScript to inject the CSS
getBridge()
.executeOnMainThread(() -> {
if (bridge != null && bridge.getWebView() != null) {
String script = String.format(
Locale.US,
"""
try {
document.documentElement.style.setProperty("--safe-area-inset-top", "%dpx");
document.documentElement.style.setProperty("--safe-area-inset-right", "%dpx");
document.documentElement.style.setProperty("--safe-area-inset-bottom", "%dpx");
document.documentElement.style.setProperty("--safe-area-inset-left", "%dpx");
window.dispatchEvent(new CustomEvent('safeAreaChanged'));

Choose a reason for hiding this comment

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

What is this 'safeAreaChanged' event for? Something specific to Android WebView?

} catch(e) { console.error('Error injecting safe area CSS:', e); }
""",
(int) topPx,
(int) rightPx,
(int) bottomPx,
(int) leftPx
);

bridge.getWebView().evaluateJavascript(script, null);
}
});
}

private void setStyle(String style, String inset) {
if (style.equals(STYLE_DEFAULT)) {
style = getStyleForTheme();
}

Window window = getActivity().getWindow();
WindowInsetsControllerCompat windowInsetsControllerCompat = WindowCompat.getInsetsController(window, window.getDecorView());
if (inset.isEmpty() || inset.equals(INSET_TOP)) {
windowInsetsControllerCompat.setAppearanceLightStatusBars(!style.equals(STYLE_DARK));
}

if (inset.isEmpty() || inset.equals(INSET_BOTTOM)) {
windowInsetsControllerCompat.setAppearanceLightNavigationBars(!style.equals(STYLE_DARK));
}
}

private void setHidden(boolean hide, String inset) {
Window window = getActivity().getWindow();
WindowInsetsControllerCompat windowInsetsControllerCompat = WindowCompat.getInsetsController(window, window.getDecorView());

if (hide) {
if (inset.isEmpty() || inset.equals(INSET_TOP)) {
windowInsetsControllerCompat.hide(WindowInsetsCompat.Type.statusBars());
}
if (inset.isEmpty() || inset.equals(INSET_BOTTOM)) {
windowInsetsControllerCompat.hide(WindowInsetsCompat.Type.navigationBars());
}
return;
}

if (inset.isEmpty() || inset.equals(INSET_TOP)) {
windowInsetsControllerCompat.show(WindowInsetsCompat.Type.systemBars());
}
if (inset.isEmpty() || inset.equals(INSET_BOTTOM)) {
windowInsetsControllerCompat.show(WindowInsetsCompat.Type.navigationBars());
}
}

private String getStyleForTheme() {
int currentNightMode = getActivity().getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK;
if (currentNightMode != Configuration.UI_MODE_NIGHT_YES) {
return STYLE_LIGHT;
}
return STYLE_DARK;
}
}
27 changes: 27 additions & 0 deletions cli/src/declarations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -710,4 +710,31 @@ export interface PluginsConfig {
*/
enabled?: boolean;
};

/**
* System Bars plugin configuration
*
* @since 8.0.0
*/
SystemBars?: {
/**
* The style of the text and icons of the system bars.

Choose a reason for hiding this comment

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

Missing what would be the default value? 'DEFAULT'?

*/
style?: string;

/**
* Hide the system bars on start.

Choose a reason for hiding this comment

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

Missing what would be the default value? "false"?

*/
hidden?: boolean;

/**
* The type of status bar animation used when showing or hiding.
*
* This option is only supported on iOS.
*
* @default 'FADE'
*
*/
animation?: 'FADE' | 'NONE';
};
}
2 changes: 1 addition & 1 deletion core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"build": "npm run clean && npm run docgen && npm run transpile && npm run rollup",
"build:nativebridge": "tsc native-bridge.ts --target es2017 --moduleResolution node --outDir build && rollup --config rollup.bridge.config.js",
"clean": "rimraf dist",
"docgen": "docgen --api CapacitorCookiesPlugin --output-readme cookies.md && docgen --api CapacitorHttpPlugin --output-readme http.md",
"docgen": "docgen --api CapacitorCookiesPlugin --output-readme cookies.md && docgen --api CapacitorHttpPlugin --output-readme http.md && docgen --api SystemBarsPlugin --output-readme systembars.md",
"prepublishOnly": "npm run build",
"rollup": "rollup --config rollup.config.js",
"transpile": "tsc",
Expand Down
Loading
Loading