From 365db93008e20ba2e4c5ce694939bc4ef75e19b4 Mon Sep 17 00:00:00 2001 From: Darryl Pogue Date: Sat, 29 Aug 2026 01:46:57 -0700 Subject: [PATCH] WIP on theme-color/viewport-fit --- .../org/apache/cordova/CordovaActivity.java | 170 +++++++++++++++--- .../org/apache/cordova/SystemBarPlugin.java | 121 ++++--------- .../cordova/engine/SystemWebChromeClient.java | 14 ++ 3 files changed, 194 insertions(+), 111 deletions(-) diff --git a/framework/src/org/apache/cordova/CordovaActivity.java b/framework/src/org/apache/cordova/CordovaActivity.java index b196f2257..6904b9ba9 100755 --- a/framework/src/org/apache/cordova/CordovaActivity.java +++ b/framework/src/org/apache/cordova/CordovaActivity.java @@ -29,6 +29,8 @@ Licensed to the Apache Software Foundation (ASF) under one import android.content.DialogInterface; import android.content.Intent; import android.content.res.Configuration; +import android.content.res.Resources; +import android.graphics.Color; import android.media.AudioManager; import android.os.Build; import android.os.Bundle; @@ -42,12 +44,15 @@ Licensed to the Apache Software Foundation (ASF) under one import android.webkit.WebViewClient; import android.widget.FrameLayout; +import androidx.annotation.ColorInt; import androidx.appcompat.app.AppCompatActivity; +import androidx.core.content.ContextCompat; import androidx.core.graphics.Insets; import androidx.core.splashscreen.SplashScreen; import androidx.core.view.ViewCompat; import androidx.core.view.WindowCompat; import androidx.core.view.WindowInsetsCompat; +import androidx.core.view.WindowInsetsControllerCompat; /** * This class is the main Android activity that represents the Cordova @@ -89,6 +94,11 @@ public class CordovaActivity extends AppCompatActivity { private static int ACTIVITY_RUNNING = 1; private static int ACTIVITY_EXITING = 2; + // These are intentionally package-internal visible + static final int VIEWPORT_FIT_AUTO = 0; + static final int VIEWPORT_FIT_CONTAIN = 1; + static final int VIEWPORT_FIT_COVER = 2; + // Keep app running when pause is received. (default = true) // If true, then the JavaScript and native code continue to run in the background // when another application (activity) is started. @@ -104,10 +114,27 @@ public class CordovaActivity extends AppCompatActivity { protected CordovaInterfaceImpl cordovaInterface; private SplashScreen splashScreen; + private View statusBarBackgroundView = null; - private boolean canEdgeToEdge = false; private boolean isFullScreen = false; + private int viewportFit = VIEWPORT_FIT_AUTO; + + /** + * The theme color for the system bars, as defined by the web content + * currently loaded in the web view. This will be the preferred color for + * the system bars, with higher precedence than the default colors defined + * in config.xml. + */ + private @ColorInt int statusBarWebViewColor = Color.TRANSPARENT; + + /** + * The color for the system bars as explicitly set using a JavaScript API + * method. This will override any other inferred colors for the system + * bars. + */ + private @ColorInt int statusBarBackgroundColor = Color.TRANSPARENT; + /** * Called when the activity is first created. */ @@ -121,7 +148,6 @@ public void onCreate(Bundle savedInstanceState) { // need to activate preferences before super.onCreate to avoid "requestFeature() must be called before adding content" exception loadConfig(); - canEdgeToEdge = preferences.getBoolean("AndroidEdgeToEdge", false); String logLevel = preferences.getString("loglevel", "ERROR"); LOG.setLogLevel(logLevel); @@ -194,8 +220,8 @@ protected void loadConfig() { Config.parser = parser; } - //Suppressing warnings in AndroidStudio - @SuppressWarnings({"deprecation", "ResourceType"}) + //Suppressing warnings in Android Studio + @SuppressWarnings({"Deprecation", "ResourceType"}) protected void createViews() { WindowCompat.setDecorFitsSystemWindows(getWindow(), false); @@ -214,33 +240,51 @@ protected void createViews() { )); // Create StatusBar view that will overlay the top inset - View statusBarView = new View(this); - statusBarView.setTag("statusBarView"); + this.statusBarBackgroundView = new View(this); + this.statusBarBackgroundView.setTag("statusBarView"); // Start with a height of 0. The inset listener below sizes the view to the status bar height, but if the window // insets never reach the root layout (observed on Android 8.0 where the decor consumes them), the view would // otherwise keep FrameLayout's default MATCH_PARENT params and cover the whole WebView. - statusBarView.setLayoutParams(new FrameLayout.LayoutParams( + this.statusBarBackgroundView.setLayoutParams(new FrameLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, 0, Gravity.TOP )); + this.statusBarBackgroundView.setBackgroundColor(this.getStatusBarBackgroundColor()); // Handle Window Insets ViewCompat.setOnApplyWindowInsetsListener(rootLayout, (v, insets) -> { - Insets bars = insets.getInsets( - WindowInsetsCompat.Type.systemBars() | WindowInsetsCompat.Type.displayCutout() - ); + Window window = getWindow(); + int types = WindowInsetsCompat.Type.systemBars() | WindowInsetsCompat.Type.displayCutout(); + Insets bars = insets.getInsets(types); + Insets imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime()); + + boolean canEdgeToEdge = preferences.getBoolean("AndroidEdgeToEdge", false); + boolean viewportCover = this.viewportFit == VIEWPORT_FIT_COVER + || (this.viewportFit == VIEWPORT_FIT_AUTO && canEdgeToEdge) + || this.isFullScreen; - boolean isStatusBarVisible = statusBarView.getVisibility() != View.GONE + boolean isStatusBarVisible = this.statusBarBackgroundView.getVisibility() != View.GONE && insets.isVisible(WindowInsetsCompat.Type.statusBars()); - int top = isStatusBarVisible && !canEdgeToEdge && !isFullScreen ? bars.top : 0; - int left = !canEdgeToEdge && !isFullScreen ? bars.left : 0; - int right = !canEdgeToEdge && !isFullScreen ? bars.right : 0; - Insets imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime()); - // When in fullscreen mode, we ignore bottom system insets (like the navigation bar) - // to allow the WebView to span the entire screen and avoid being pushed up. - int bottom = isFullScreen ? 0 : canEdgeToEdge ? imeInsets.bottom : Math.max(bars.bottom, imeInsets.bottom); + int top = (!isStatusBarVisible || viewportCover) ? 0 : bars.top; + int left = viewportCover ? 0 : bars.left; + int right = viewportCover ? 0 : bars.right; + + // We don't want the keyboard to push the webview up, so we force the bottom inset to 0 when the keyboard is showing. + int bottom = imeInsets.bottom > 0 ? 0 : (viewportCover ? 0 : bars.bottom); + + int uiOptions = window.getDecorView().getSystemUiVisibility(); + if (viewportCover) { + uiOptions |= WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS; + } else { + uiOptions &= ~WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS; + } + window.getDecorView().setSystemUiVisibility(uiOptions); + window.setNavigationBarColor(Color.TRANSPARENT); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + window.setNavigationBarContrastEnforced(true); + } FrameLayout.LayoutParams webViewParams = (FrameLayout.LayoutParams) webView.getLayoutParams(); // Only update layout margins if the values have actually changed. @@ -256,14 +300,19 @@ protected void createViews() { top, Gravity.TOP ); - statusBarView.setLayoutParams(statusBarParams); + this.statusBarBackgroundView.setLayoutParams(statusBarParams); + if (!viewportCover) { + // We need to consume the insets because we've padded around them + return new WindowInsetsCompat.Builder(insets).setInsets(types, Insets.NONE).build(); + } return insets; }); rootLayout.addView(webView); - rootLayout.addView(statusBarView); + rootLayout.addView(this.statusBarBackgroundView); + rootLayout.setId(android.R.id.content); setContentView(rootLayout); rootLayout.post(() -> ViewCompat.requestApplyInsets(rootLayout)); webView.requestFocusFromTouch(); @@ -545,6 +594,8 @@ public boolean onOptionsItemSelected(MenuItem item) { * @return Object or null */ public Object onMessage(String id, Object data) { + final CordovaActivity me = this; + if ("onReceivedError".equals(id)) { JSONObject d = (JSONObject) data; try { @@ -552,6 +603,19 @@ public Object onMessage(String id, Object data) { } catch (JSONException e) { e.printStackTrace(); } + } else if (id.equals("onViewportFitChanged")) { + this.viewportFit = (int) data; + + me.runOnUiThread(() -> { + View rootLayout = me.findViewById(android.R.id.content); + rootLayout.post(() -> ViewCompat.requestApplyInsets(rootLayout)); + }); + } else if (id.equals("onReceivedThemeColor")) { + this.statusBarWebViewColor = (int) data; + me.runOnUiThread(me::updateStatusBarColors); + } else if (id.equals("setStatusBarBackgroundColor")) { + this.statusBarBackgroundColor = (int) data; + me.runOnUiThread(me::updateStatusBarColors); } else if ("exit".equals(id)) { finish(); } @@ -620,4 +684,70 @@ public void onRequestPermissionsResult(int requestCode, String permissions[], protected boolean showInitialSplashScreen() { return true; } + + protected @ColorInt int getStatusBarBackgroundColor() { + if (this.statusBarBackgroundColor != Color.TRANSPARENT) { + // Forced overridden color from the JS API + return this.statusBarBackgroundColor; + } else if (this.statusBarWebViewColor != Color.TRANSPARENT) { + // The value of the web content's theme-color + return this.statusBarWebViewColor; + } else { + int colorId; + Resources resources = getResources(); + + // The StatusBarBackgroundColor preference value + colorId = resources.getIdentifier("cdv_statusbar_background_color", "color", getPackageName()); + if (colorId != 0) { + return ContextCompat.getColor(this, colorId); + } + + // The BackgroundColor preference value + colorId = resources.getIdentifier("cdv_background_color", "color", getPackageName()); + if (colorId != 0) { + return ContextCompat.getColor(this, colorId); + } + + // The system fallback value + // (using hex values instead of "android.R.color" for backwards compatibility) + if ((resources.getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES) { + // If night mode: "#121318" (android.R.color.system_background_dark) + return 0xFF121318; + } else { + // If day mode: "#FAF8FF" (android.R.color.system_background_light) + return 0xFFFAF8FF; + } + } + } + + + /** + * Determines if the supplied color's appearance is light. + * + * @param color color + * @return boolean value true is returned when the color is light. + */ + private boolean isColorLight(@ColorInt int color) { + if (color == Color.TRANSPARENT) { + // Return true if we're not in dark/night mode + return (getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK) != Configuration.UI_MODE_NIGHT_YES; + } + + double r = Color.red(color) / 255.0; + double g = Color.green(color) / 255.0; + double b = Color.blue(color) / 255.0; + double luminance = 0.299 * r + 0.587 * g + 0.114 * b; + return luminance > 0.5; + } + + private void updateStatusBarColors() { + @ColorInt int bgColor = this.getStatusBarBackgroundColor(); + this.statusBarBackgroundView.setBackgroundColor(bgColor); + + Window window = getWindow(); + WindowInsetsControllerCompat controllerCompat = WindowCompat.getInsetsController(window, window.getDecorView()); + + // Automatically set the font and icon color of the system bars based on background color. + controllerCompat.setAppearanceLightStatusBars(isColorLight(bgColor)); + } } diff --git a/framework/src/org/apache/cordova/SystemBarPlugin.java b/framework/src/org/apache/cordova/SystemBarPlugin.java index 00b0f4833..911f79e7f 100644 --- a/framework/src/org/apache/cordova/SystemBarPlugin.java +++ b/framework/src/org/apache/cordova/SystemBarPlugin.java @@ -31,6 +31,7 @@ Licensed to the Apache Software Foundation (ASF) under one import android.view.WindowInsetsController; import android.widget.FrameLayout; +import androidx.annotation.ColorInt; import androidx.core.content.ContextCompat; import androidx.core.view.ViewCompat; import androidx.core.view.WindowCompat; @@ -40,15 +41,12 @@ Licensed to the Apache Software Foundation (ASF) under one import org.json.JSONArray; import org.json.JSONException; -import java.util.Objects; - public class SystemBarPlugin extends CordovaPlugin { static final String PLUGIN_NAME = "SystemBarPlugin"; // Internal variables private Context context; private Resources resources; - private Integer overrideStatusBarBackgroundColor = null; private boolean canEdgeToEdge = false; @@ -85,7 +83,22 @@ public boolean execute(String action, JSONArray args, CallbackContext callbackCo boolean visible = args.getBoolean(0); cordova.getActivity().runOnUiThread(() -> setStatusBarVisible(visible)); } else if ("setStatusBarBackgroundColor".equals(action)) { - cordova.getActivity().runOnUiThread(() -> setStatusBarBackgroundColor(args)); + @ColorInt int statusBarBackgroundColor = colorIntFromJson(args); + webView.getPluginManager().postMessage("setStatusBarBackgroundColor", statusBarBackgroundColor); + } else if ("_setMetaThemeColor".equals(action)) { + @ColorInt int metaThemeColor = colorIntFromJson(args); + webView.getPluginManager().postMessage("onReceivedThemeColor", metaThemeColor); + } else if ("_setViewportFit".equals(action)) { + int viewportFit = CordovaActivity.VIEWPORT_FIT_AUTO; + String value = args.optString(0, "auto"); + + if ("contain".equals(value)) { + viewportFit = CordovaActivity.VIEWPORT_FIT_CONTAIN; + } else if ("cover".equals(value)) { + viewportFit = CordovaActivity.VIEWPORT_FIT_COVER; + } + + webView.getPluginManager().postMessage("onViewportFitChanged", viewportFit); } else { return false; } @@ -124,24 +137,18 @@ private void setStatusBarVisible(final boolean visible) { } /** - * Allow the app to override the status bar background color from JS API. - * If the supplied ARGB is invalid or fails to parse, it will silently ignore - * the change request. - * - * @param argbVals {R, G, B, A} + * Converts a JSON array of RGBA components to an Android int color. + * @param argbVals An array of RGB int values and an (optional) alpha float value. + * @return An Android int color. + * @throws JSONException if parsing fails. */ - private void setStatusBarBackgroundColor(JSONArray argbVals) { - try { - int r = argbVals.getInt(0); - int g = argbVals.getInt(1); - int b = argbVals.getInt(2); - int a = Math.round(255 * (float)argbVals.optDouble(3, 1.0)); - - overrideStatusBarBackgroundColor = Color.argb(a, r, g, b); - updateStatusBar(overrideStatusBarBackgroundColor); - } catch (JSONException e) { - // Silently skip - } + private @ColorInt int colorIntFromJson(JSONArray argbVals) throws JSONException { + int r = argbVals.getInt(0); + int g = argbVals.getInt(1); + int b = argbVals.getInt(2); + int a = Math.round(255 * (float)argbVals.optDouble(3, 1.0)); + + return Color.argb(a, r, g, b); } /** @@ -159,20 +166,6 @@ private void updateSystemBars() { rootViewBackgroundColor = canEdgeToEdge ? Color.TRANSPARENT : getUiModeColor(); } updateRootView(rootViewBackgroundColor); - - // Update StatusBar Background Color - Integer statusBarBackgroundColor; - if (overrideStatusBarBackgroundColor != null) { - statusBarBackgroundColor = overrideStatusBarBackgroundColor; - } else if (preferences.contains("StatusBarBackgroundColor")) { - statusBarBackgroundColor = getPreferenceStatusBarBackgroundColor(); - } else if (preferences.contains("BackgroundColor")) { - statusBarBackgroundColor = rootViewBackgroundColor; - } else { - statusBarBackgroundColor = canEdgeToEdge ? Color.TRANSPARENT : getUiModeColor(); - } - - updateStatusBar(statusBarBackgroundColor); } /** @@ -223,39 +216,13 @@ private void updateRootView(int bgColor) { } } - /** - * Updates the statusBarView background color with the supplied color int. - * It will also determine if the background color is light or dark to properly adjust the - * appearance of the status bar so the font will not clash with the background. - * - * @param bgColor Background color - */ - private void updateStatusBar(int bgColor) { - Window window = cordova.getActivity().getWindow(); - - View statusBar = getStatusBarView(webView); - if (statusBar != null) { - statusBar.setBackgroundColor(bgColor); - } - - // Automatically set the font and icon color of the system bars based on background color. - boolean isStatusBarBackgroundColorLight; - if(bgColor == Color.TRANSPARENT) { - isStatusBarBackgroundColorLight = isColorLight(getUiModeColor()); - } else { - isStatusBarBackgroundColorLight = isColorLight(bgColor); - } - WindowInsetsControllerCompat controllerCompat = WindowCompat.getInsetsController(window, window.getDecorView()); - controllerCompat.setAppearanceLightStatusBars(isStatusBarBackgroundColorLight); - } - /** * Determines if the supplied color's appearance is light. * * @param color color * @return boolean value true is returned when the color is light. */ - private static boolean isColorLight(int color) { + private static boolean isColorLight(@ColorInt int color) { double r = Color.red(color) / 255.0; double g = Color.green(color) / 255.0; double b = Color.blue(color) / 255.0; @@ -263,16 +230,6 @@ private static boolean isColorLight(int color) { return luminance > 0.5; } - /** - * Returns the StatusBarBackgroundColor preference value or {@link #getUiModeColor()} as fallback. - * - * @return Integer - */ - private Integer getPreferenceStatusBarBackgroundColor() { - String colorString = preferences.getString("StatusBarBackgroundColor", null); - return Objects.requireNonNullElse(parseColorFromString(colorString), getUiModeColor()); - } - /** * Returns the BackgroundColor preference value. * If the value is missing or fails to decode, null is returned. @@ -344,7 +301,7 @@ private View getStatusBarView(CordovaWebView webView) { * @return int color */ @SuppressLint("DiscouragedApi") - private int getUiModeColor() { + private @ColorInt int getUiModeColor() { boolean isNightMode = (resources.getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES; String fallbackColor = isNightMode ? "#121318" : "#FAF8FF"; int colorResId = resources.getIdentifier("cdv_background_color", "color", context.getPackageName()); @@ -352,22 +309,4 @@ private int getUiModeColor() { ? ContextCompat.getColor(context, colorResId) : Color.parseColor(fallbackColor); } - - /** - * Parses a color string provided by app developers. - * If the color string is empty or unable to parse, null is returned. - * - * @param colorPref hex string value, #AARRGGBB or #RRGGBB - * @return Integer|null - */ - private Integer parseColorFromString(final String colorPref) { - if (colorPref == null || colorPref.isEmpty()) return null; - - try { - return Color.parseColor(colorPref); - } catch (IllegalArgumentException ignore) { - LOG.e(PLUGIN_NAME, "Invalid color hex code. Valid format: #RRGGBB or #AARRGGBB"); - return null; - } - } } diff --git a/framework/src/org/apache/cordova/engine/SystemWebChromeClient.java b/framework/src/org/apache/cordova/engine/SystemWebChromeClient.java index c10c666d7..827cae95a 100755 --- a/framework/src/org/apache/cordova/engine/SystemWebChromeClient.java +++ b/framework/src/org/apache/cordova/engine/SystemWebChromeClient.java @@ -32,7 +32,9 @@ Licensed to the Apache Software Foundation (ASF) under one import android.content.ActivityNotFoundException; import android.content.Intent; import android.content.pm.PackageManager; +import android.graphics.Color; import android.net.Uri; +import android.os.Build; import android.provider.MediaStore; import android.view.Gravity; import android.view.View; @@ -51,6 +53,7 @@ Licensed to the Apache Software Foundation (ASF) under one import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.RequiresApi; import androidx.core.content.FileProvider; import org.apache.cordova.CordovaDialogsHelper; @@ -357,4 +360,15 @@ public void onPermissionRequest(final PermissionRequest request) { public void destroyLastDialog(){ dialogsHelper.destroyLastDialog(); } + + /* + @RequiresApi(api = Build.VERSION_CODES.O) + public void onReceivedThemeColor(WebView view, Color color) { + parentEngine.pluginManager.postMessage("onReceivedThemeColor", color.toArgb()); + } + + public void onViewportFitChanged(WebView view, int viewportFit) { + parentEngine.pluginManager.postMessage("onViewportFitChanged", viewportFit); + } + */ }