diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc322f5a59..a589dcc079 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,54 +26,6 @@ on: - '*' jobs: - codeql: - name: CodeQL Analysis - runs-on: ubuntu-latest - - permissions: - contents: read - security-events: write - - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - persist-credentials: false - fetch-depth: 1 - - - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0 - with: - distribution: 'temurin' - java-version: '17' - - # "bin/templates/platform_www/cordova.js" is ignored because it is a generated file. - # It contains mixed content from the npm package "cordova-js" and "./cordova-js-src". - # The report might not be resolvable because of the external package. - # If the report is related to this repository, it would be detected when scanning "./cordova-js-src". - - uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 - with: - languages: javascript, java-kotlin - queries: security-and-quality - # When 'build-mode' is set to 'none', CodeQL will create a database without - # requiring a build for Java. If Kotlin files are added in the future, build - # will be required. - build-mode: none - config: | - paths-ignore: - - coverage - - node_modules - - templates/project/assets/www/cordova.js - - test/androidx/app/src/main/assets/www/cordova.js - - framework/build/intermediates - - framework/build/outputs - - framework/build/reports - - framework/build/tmp - - test/androidx/build/intermediates - - test/androidx/build/outputs - - test/androidx/build/reports - - test/androidx/build/tmp - - - uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 - test: name: NodeJS ${{ matrix.node-version }} on ${{ matrix.os }} @@ -85,7 +37,7 @@ jobs: strategy: matrix: node-version: [20.x, 22.x, 24.x, 26.x] - os: [ubuntu-latest, windows-latest, macos-26] + os: [ubuntu-latest, macos-26] steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -97,7 +49,8 @@ jobs: with: node-version: ${{ matrix.node-version }} - - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0 + - name: set up JDK 17 + uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0 with: distribution: 'temurin' java-version: '17' @@ -113,10 +66,3 @@ jobs: npm cit env: CI: true - - - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 - if: success() - with: - name: ${{ runner.os }} node.js ${{ matrix.node-version }} - token: ${{ secrets.CORDOVA_CODECOV_TOKEN }} - fail_ci_if_error: false diff --git a/cordova-js-src/plugin/android/splashscreen.js b/cordova-js-src/plugin/android/splashscreen.js index 9d9b3b4501..8810588d58 100644 --- a/cordova-js-src/plugin/android/splashscreen.js +++ b/cordova-js-src/plugin/android/splashscreen.js @@ -21,7 +21,7 @@ var exec = require('cordova/exec'); var splashscreen = { show: function () { - console.log('"navigator.splashscreen.show()" is unsupported on Android.'); + exec(null, null, 'CordovaSplashScreenPlugin', 'show', []); }, hide: function () { exec(null, null, 'CordovaSplashScreenPlugin', 'hide', []); diff --git a/framework/cordova.gradle b/framework/cordova.gradle index d36f716deb..68eca7bfc5 100644 --- a/framework/cordova.gradle +++ b/framework/cordova.gradle @@ -173,6 +173,9 @@ def doApplyCordovaConfigCustomization() { if (project.hasProperty('cdvAndroidXWebKitVersion')) { cordovaConfig.ANDROIDX_WEBKIT_VERSION = cdvAndroidXWebKitVersion } + if (project.hasProperty('cdvAndroidXCoreSplashscreenVersion')) { + cordovaConfig.ANDROIDX_CORE_SPLASHSCREEN_VERSION = cdvAndroidXCoreSplashscreenVersion + } if (!cordovaConfig.BUILD_TOOLS_VERSION) { cordovaConfig.BUILD_TOOLS_VERSION = doFindLatestInstalledBuildTools( diff --git a/framework/gradle.properties b/framework/gradle.properties index 7b2379018f..bfab5da496 100644 --- a/framework/gradle.properties +++ b/framework/gradle.properties @@ -28,7 +28,6 @@ # The setting is particularly useful for tweaking memory settings. org.gradle.jvmargs=-Xmx1536m android.useAndroidX=true -android.enableJetifier=true # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit diff --git a/framework/src/org/apache/cordova/CordovaActivity.java b/framework/src/org/apache/cordova/CordovaActivity.java index a8d4379ee0..797e19bfba 100755 --- a/framework/src/org/apache/cordova/CordovaActivity.java +++ b/framework/src/org/apache/cordova/CordovaActivity.java @@ -212,6 +212,7 @@ protected void createViews() { ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT )); + webView.setFilterTouchesWhenObscured(preferences.getBoolean("FilterTouchesWhenObscured", true)); // Create StatusBar view that will overlay the top inset View statusBarView = new View(this); diff --git a/framework/src/org/apache/cordova/CordovaWebViewImpl.java b/framework/src/org/apache/cordova/CordovaWebViewImpl.java index 039a503ef2..a4af69be1e 100644 --- a/framework/src/org/apache/cordova/CordovaWebViewImpl.java +++ b/framework/src/org/apache/cordova/CordovaWebViewImpl.java @@ -267,6 +267,8 @@ public void showWebPage(String url, boolean openExternal, boolean clearHistory, } else { LOG.e(TAG, "Error loading url " + url, e); } + } catch (Exception e) { + LOG.e(TAG, "Error loading url " + url + " with error on file URI exposed", e); } } diff --git a/framework/src/org/apache/cordova/SplashScreenPlugin.java b/framework/src/org/apache/cordova/SplashScreenPlugin.java index 8befc44e07..b057a3d845 100644 --- a/framework/src/org/apache/cordova/SplashScreenPlugin.java +++ b/framework/src/org/apache/cordova/SplashScreenPlugin.java @@ -22,26 +22,58 @@ Licensed to the Apache Software Foundation (ASF) under one import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.annotation.SuppressLint; +import android.app.Activity; +import android.app.Dialog; +import android.app.ProgressDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.content.res.ColorStateList; +import android.content.res.Configuration; +import android.graphics.Color; +import android.graphics.drawable.ColorDrawable; import android.os.Handler; +import android.view.Display; +import android.view.Gravity; import android.view.View; +import android.view.ViewGroup.LayoutParams; +import android.view.WindowManager; import android.view.animation.AccelerateInterpolator; - +import android.view.animation.AlphaAnimation; +import android.view.animation.Animation; +import android.view.animation.DecelerateInterpolator; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.ProgressBar; +import android.widget.RelativeLayout; import androidx.annotation.NonNull; import androidx.core.splashscreen.SplashScreen; import androidx.core.splashscreen.SplashScreenViewProvider; - import org.json.JSONArray; import org.json.JSONException; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + @SuppressLint("LongLogTag") public class SplashScreenPlugin extends CordovaPlugin { static final String PLUGIN_NAME = "CordovaSplashScreenPlugin"; + private static final boolean DEFAULT_HAS_CUSTOM_SPLASHSCREENS = false; + // Default config preference values private static final boolean DEFAULT_AUTO_HIDE = true; - private static final int DEFAULT_DELAY_TIME = -1; + private static final int DEFAULT_DELAY_TIME = -1; // milliseconds private static final boolean DEFAULT_FADE = true; - private static final int DEFAULT_FADE_TIME = 500; + private static final int DEFAULT_FADE_TIME = 500; // milliseconds + + // Default legacy config preference values + private static final String DEFAULT_SPLASH_RESOURCE = "screen"; + private static final boolean DEFAULT_SHOW_ONLY_FIRST_TIME = true; + private static final boolean DEFAULT_MAINTAIN_ASPECT_RATIO = false; + private static final int DEFAULT_BACKGROUND_COLOR = Color.BLACK; + private static final boolean DEFAULT_SHOW_SPINNER = true; + private static final String DEFAULT_SPINNER_COLOR = null; // Config preference values /** @@ -61,43 +93,83 @@ public class SplashScreenPlugin extends CordovaPlugin { */ private int fadeDuration; + private static boolean hasCustomSplashscreens; + // Internal variables /** * Boolean flag to determine if the splash screen remains visible. */ private boolean keepOnScreen = true; + private SplashScreenBehaviourCollection behaviours; + @Override protected void pluginInitialize() { + behaviours = new SplashScreenBehaviourCollection(); + // Auto Hide & Delay Settings - autoHide = preferences.getBoolean("AutoHideSplashScreen", DEFAULT_AUTO_HIDE); - delayTime = preferences.getInteger("SplashScreenDelay", DEFAULT_DELAY_TIME); + boolean autoHide = preferences.getBoolean("AutoHideSplashScreen", DEFAULT_AUTO_HIDE); + int delayTime = preferences.getInteger("SplashScreenDelay", DEFAULT_DELAY_TIME); LOG.d(PLUGIN_NAME, "Auto Hide: " + autoHide); if (delayTime != DEFAULT_DELAY_TIME) { LOG.d(PLUGIN_NAME, "Delay: " + delayTime + "ms"); } // Fade & Fade Duration - isFadeEnabled = preferences.getBoolean("FadeSplashScreen", DEFAULT_FADE); - fadeDuration = preferences.getInteger("FadeSplashScreenDuration", DEFAULT_FADE_TIME); + boolean isFadeEnabled = preferences.getBoolean("FadeSplashScreen", DEFAULT_FADE); + int fadeDuration = preferences.getInteger("FadeSplashScreenDuration", DEFAULT_FADE_TIME); LOG.d(PLUGIN_NAME, "Fade: " + isFadeEnabled); if (isFadeEnabled) { LOG.d(PLUGIN_NAME, "Fade Duration: " + fadeDuration + "ms"); } + + Context context = cordova.getContext(); + boolean showSpinner = preferences.getBoolean("ShowSplashScreenSpinner", DEFAULT_SHOW_SPINNER); + hasCustomSplashscreens = preferences.getBoolean("HasCustomSplashscreens", DEFAULT_HAS_CUSTOM_SPLASHSCREENS); + if (!showSpinner && !hasCustomSplashscreens) { + // Use only the Android Splashscreen API + behaviours.registerBehaviour(new AndroidSplashScreenBehaviour(context, autoHide, delayTime, isFadeEnabled, fadeDuration, webView)); + + } else { + // Using the Android Splashscreen API is mandatory, so we'll try to use it as least as possible + behaviours.registerBehaviour(new AndroidSplashScreenBehaviour(context, true, 1, false, 0, webView)); + + // And we'll use the legacy code, from cordova-plugin-splashscreen + Activity activity = cordova.getActivity(); + String splashResource = preferences.getString("SplashScreen", DEFAULT_SPLASH_RESOURCE); + boolean showOnlyFirstTime = preferences.getBoolean("SplashShowOnlyFirstTime", DEFAULT_SHOW_ONLY_FIRST_TIME); + boolean maintainAspectRatio = preferences.getBoolean("SplashMaintainAspectRatio", DEFAULT_MAINTAIN_ASPECT_RATIO); + int backgroundColor = preferences.getInteger("backgroundColor", DEFAULT_BACKGROUND_COLOR); + String spinnerColor = preferences.getString("SplashScreenSpinnerColor", DEFAULT_SPINNER_COLOR); + + behaviours.registerBehaviour(new LegacySplashScreenBehaviour( + autoHide, + delayTime, + isFadeEnabled, + fadeDuration, + splashResource, + showOnlyFirstTime, + maintainAspectRatio, + backgroundColor, + showSpinner, + spinnerColor, + activity, + webView.getView(), + webView + )); + } } @Override public boolean execute( - String action, - JSONArray args, - CallbackContext callbackContext + String action, + JSONArray args, + CallbackContext callbackContext ) throws JSONException { - if (action.equals("hide") && autoHide == false) { - /* - * The `.hide()` method can only be triggered if the `splashScreenAutoHide` - * is set to `false`. - */ - keepOnScreen = false; + if (action.equals("show")) { + behaviours.runInAllBehaviours(SplashScreenBehaviour::show); + } else if (action.equals("hide")) { + behaviours.runInAllBehaviours(SplashScreenBehaviour::hide); } else { return false; } @@ -108,66 +180,524 @@ public boolean execute( @Override public Object onMessage(String id, Object data) { - switch (id) { - case "setupSplashScreen": - setupSplashScreen((SplashScreen) data); - break; + behaviours.runInAllBehaviours(behaviour -> behaviour.onMessage(id, data)); + return null; + } - case "onPageFinished": - attemptCloseOnPageFinished(); - break; + @Override + public void onPause(boolean multitasking) { + behaviours.runInAllBehaviours(SplashScreenBehaviour::onPauseOrDestroy); + } + + @Override + public void onDestroy() { + behaviours.runInAllBehaviours(SplashScreenBehaviour::onPauseOrDestroy); + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + behaviours.runInAllBehaviours(behaviour -> behaviour.onConfigurationChanged(newConfig)); + } + + private interface SplashScreenBehaviour { + void show(); + void hide(); + void onPauseOrDestroy(); + void onConfigurationChanged(Configuration newConfig); + void onMessage(String id, Object data); + } + + private static class SplashScreenBehaviourCollection { + private List behaviours; + + public SplashScreenBehaviourCollection() { + this.behaviours = new ArrayList<>(); } - return null; + public synchronized void registerBehaviour(SplashScreenBehaviour behaviour) { + behaviours.add(behaviour); + } + + public synchronized void runInAllBehaviours(Consumer lambda) { + for (SplashScreenBehaviour behaviour : behaviours) { + lambda.accept(behaviour); + } + } } - private void setupSplashScreen(SplashScreen splashScreen) { - // Setup Splash Screen Delay - splashScreen.setKeepOnScreenCondition(() -> keepOnScreen); + private static class AndroidSplashScreenBehaviour implements SplashScreenBehaviour { + // Context + private Context context; - // auto hide splash screen when custom delay is defined. - if (autoHide && delayTime != DEFAULT_DELAY_TIME) { - Handler splashScreenDelayHandler = new Handler(cordova.getContext().getMainLooper()); - splashScreenDelayHandler.postDelayed(() -> keepOnScreen = false, delayTime); + // Configuration variables + private boolean autoHide; + private int delayTime; + private boolean isFadeEnabled; + private int fadeDuration; + + // Internal variables + private boolean keepOnScreen; + CordovaWebView cordovaWebView; + + public AndroidSplashScreenBehaviour(Context context, boolean autoHide, int delayTime, boolean isFadeEnabled, int fadeDuration, CordovaWebView cordovaWebView) { + // Context + this.context = context; + // Configuration variables + this.autoHide = autoHide; + this.delayTime = delayTime; + this.isFadeEnabled = isFadeEnabled; + this.fadeDuration = fadeDuration; + // Internal variables + this.keepOnScreen = true; + this.cordovaWebView = cordovaWebView; } - // auto hide splash screen with default delay (-1) delay is controlled by the - // `onPageFinished` message. + @Override + public void show() { + // no-op + // The Android splash screen is always shown at startup + // It can also not be shown again afterwards + // So this method does nothing + } - // If auto hide is disabled (false), the hiding of the splash screen must be determined & - // triggered by the front-end code with the `navigator.splashscreen.hide()` method. + @Override + public void hide() { + keepOnScreen = false; + } - if (isFadeEnabled) { - // Setup the fade - splashScreen.setOnExitAnimationListener(new SplashScreen.OnExitAnimationListener() { + @Override + public void onPauseOrDestroy() { + // no-op + // Everything is managed by Android, so there is nothing to cleanup + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + // no-op + // Everything is managed by Android, so there is nothing to manage + } + + @Override + public void onMessage(String id, Object data) { + switch (id) { + case "setupSplashScreen": + setupSplashScreen((SplashScreen) data); + break; + + case "onPageFinished": + attemptCloseOnPageFinished(); + break; + } + } + + private void setupSplashScreen(SplashScreen splashScreen) { + // Setup Splash Screen Delay + splashScreen.setKeepOnScreenCondition(() -> keepOnScreen); + + // auto hide splash screen when custom delay is defined. + if (autoHide && delayTime > 0) { + Handler splashScreenDelayHandler = new Handler(context.getMainLooper()); + splashScreenDelayHandler.postDelayed(() -> keepOnScreen = false, delayTime); + } + + // auto hide splash screen with default delay (-1) delay is controlled by the + // `onPageFinished` message. + + // If auto hide is disabled (false), the hiding of the splash screen must be determined & + // triggered by the front-end code with the `navigator.splashscreen.hide()` method. + + if (isFadeEnabled) { + // Setup the fade + splashScreen.setOnExitAnimationListener(new SplashScreen.OnExitAnimationListener() { + @Override + public void onSplashScreenExit(@NonNull SplashScreenViewProvider splashScreenViewProvider) { + View splashScreenView = splashScreenViewProvider.getView(); + + splashScreenView + .animate() + .alpha(0.0f) + .setDuration(fadeDuration) + .setStartDelay(0) + .setInterpolator(new AccelerateInterpolator()) + .setListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + super.onAnimationEnd(animation); + splashScreenViewProvider.remove(); + cordovaWebView.getPluginManager().postMessage("updateSystemBars", null); + } + }).start(); + } + }); + } else { + if(!hasCustomSplashscreens){ + cordovaWebView.getPluginManager().postMessage("updateSystemBars", null); + } + } + } + + private void attemptCloseOnPageFinished() { + if (autoHide && delayTime <= 0) { + keepOnScreen = false; + } + } + } + + private static class LegacySplashScreenBehaviour implements SplashScreenBehaviour { + // Configuration variables + private boolean autoHide; + private int delayTime; + private boolean isFadeEnabled; + private int fadeDuration; + private String splashResource; + private boolean showOnlyFirstTime; + private boolean maintainAspectRatio; + private int backgroundColor; + private boolean showSpinner; + private String spinnerColor; + // Activity variables + private Activity activity; + private View webView; + private CordovaWebView cordovaWebView; + // Internal variables + private Dialog splashDialog; + private ProgressDialog spinnerDialog; + private boolean firstShow; + private boolean lastHideAfterDelay; // https://issues.apache.org/jira/browse/CB-9094 + private ImageView splashImageView; + private int orientation; + + public LegacySplashScreenBehaviour( + boolean autoHide, + int delayTime, + boolean isFadeEnabled, + int fadeDuration, + String splashResource, + boolean showOnlyFirstTime, + boolean maintainAspectRatio, + int backgroundColor, + boolean showSpinner, + String spinnerColor, + Activity activity, + View webView, + CordovaWebView cordovaWebView + ) { + // Configuration variables + this.autoHide = autoHide; + this.delayTime = delayTime; + this.isFadeEnabled = isFadeEnabled; + this.fadeDuration = fadeDuration; + this.splashResource = splashResource; + this.showOnlyFirstTime = showOnlyFirstTime; + this.maintainAspectRatio = maintainAspectRatio; + this.backgroundColor = backgroundColor; + this.showSpinner = showSpinner; + this.spinnerColor = spinnerColor; + // Activity variables + this.activity = activity; + this.webView = webView; + this.cordovaWebView = cordovaWebView; + // Internal variables + this.splashDialog = null; + this.spinnerDialog = null; + this.firstShow = true; + this.lastHideAfterDelay = false; + this.splashImageView = null; + this.orientation = 0; + + // Make WebView invisible while loading URL + // CB-11326 Ensure we're calling this on UI thread + activity.runOnUiThread(new Runnable() { @Override - public void onSplashScreenExit(@NonNull SplashScreenViewProvider splashScreenViewProvider) { - View splashScreenView = splashScreenViewProvider.getView(); - - splashScreenView - .animate() - .alpha(0.0f) - .setDuration(fadeDuration) - .setStartDelay(0) - .setInterpolator(new AccelerateInterpolator()) - .setListener(new AnimatorListenerAdapter() { + public void run() { + webView.setVisibility(View.INVISIBLE); + } + }); + + // Save initial orientation. + orientation = activity.getResources().getConfiguration().orientation; + + if (firstShow) { + showSplashScreen(autoHide); + } + + if (showOnlyFirstTime) { + firstShow = false; + } + } + + private int getSplashId() { + int drawableId = 0; + if (splashResource != null) { + drawableId = activity.getResources().getIdentifier(splashResource, "drawable", activity.getClass().getPackage().getName()); + if (drawableId == 0) { + drawableId = activity.getResources().getIdentifier(splashResource, "drawable", activity.getPackageName()); + } + } + return drawableId; + } + + private int getFadeDuration () { + int fadeSplashScreenDuration = isFadeEnabled ? fadeDuration : 0; + + if (fadeSplashScreenDuration < 30) { + // [CB-9750] This value used to be in decimal seconds, so we will assume that if someone specifies 10 + // they mean 10 seconds, and not the meaningless 10ms + fadeSplashScreenDuration *= 1000; + } + + return fadeSplashScreenDuration; + } + + @Override + public void show() { + activity.runOnUiThread(new Runnable() { + public void run() { + cordovaWebView.postMessage("splashscreen", "show"); + } + }); + } + + @Override + public void hide() { + activity.runOnUiThread(new Runnable() { + public void run() { + cordovaWebView.postMessage("splashscreen", "hide"); + } + }); + } + + @Override + public void onPauseOrDestroy() { + // hide the splash screen to avoid leaking a window + this.removeSplashScreen(true); + } + + @Override + public void onMessage(String id, Object data) { + if ("splashscreen".equals(id)) { + if ("hide".equals(data.toString())) { + this.removeSplashScreen(false); + } else { + this.showSplashScreen(false); + } + } else if ("spinner".equals(id)) { + if ("stop".equals(data.toString())) { + webView.setVisibility(View.VISIBLE); + } + } else if ("onReceivedError".equals(id)) { + this.spinnerStop(); + } + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + if (newConfig.orientation != orientation) { + orientation = newConfig.orientation; + + // Splash drawable may change with orientation, so reload it. + if (splashImageView != null) { + int drawableId = getSplashId(); + if (drawableId != 0) { + splashImageView.setImageDrawable(activity.getResources().getDrawable(drawableId)); + } + } + } + } + + private void removeSplashScreen(final boolean forceHideImmediately) { + activity.runOnUiThread(new Runnable() { + public void run() { + if (splashDialog != null && splashImageView != null && splashDialog.isShowing()) {//check for non-null splashImageView, see https://issues.apache.org/jira/browse/CB-12277 + final int fadeSplashScreenDuration = getFadeDuration(); + // CB-10692 If the plugin is being paused/destroyed, skip the fading and hide it immediately + if (fadeSplashScreenDuration > 0 && forceHideImmediately == false) { + AlphaAnimation fadeOut = new AlphaAnimation(1, 0); + fadeOut.setInterpolator(new DecelerateInterpolator()); + fadeOut.setDuration(fadeSplashScreenDuration); + + splashImageView.setAnimation(fadeOut); + splashImageView.startAnimation(fadeOut); + + fadeOut.setAnimationListener(new Animation.AnimationListener() { + @Override + public void onAnimationStart(Animation animation) { + spinnerStop(); + } + + @Override + public void onAnimationEnd(Animation animation) { + if (splashDialog != null && splashImageView != null && splashDialog.isShowing()) {//check for non-null splashImageView, see https://issues.apache.org/jira/browse/CB-12277 + splashDialog.dismiss(); + splashDialog = null; + splashImageView = null; + } + cordovaWebView.getPluginManager().postMessage("updateSystemBars", null); + } + @Override - public void onAnimationEnd(Animator animation) { - super.onAnimationEnd(animation); - splashScreenViewProvider.remove(); - webView.getPluginManager().postMessage("updateSystemBars", null); + public void onAnimationRepeat(Animation animation) { } - }).start(); + }); + } else { + spinnerStop(); + splashDialog.dismiss(); + splashDialog = null; + splashImageView = null; + cordovaWebView.getPluginManager().postMessage("updateSystemBars", null); + } + } } }); - } else { - webView.getPluginManager().postMessage("updateSystemBars", null); } - } - private void attemptCloseOnPageFinished() { - if (autoHide && delayTime == DEFAULT_DELAY_TIME) { - keepOnScreen = false; + /** + * Shows the splash screen over the full Activity + */ + @SuppressWarnings("deprecation") + private void showSplashScreen(final boolean hideAfterDelay) { + final int drawableId = getSplashId(); + + final int fadeSplashScreenDuration = getFadeDuration(); + final int effectiveSplashDuration = Math.max(0, delayTime - fadeSplashScreenDuration); + + lastHideAfterDelay = hideAfterDelay; + + // Prevent to show the splash dialog if the activity is in the process of finishing + if (activity.isFinishing()) { + return; + } + // If the splash dialog is showing don't try to show it again + if (splashDialog != null && splashDialog.isShowing()) { + return; + } + if (drawableId == 0 || (delayTime <= 0 && hideAfterDelay)) { + return; + } + + activity.runOnUiThread(new Runnable() { + public void run() { + // Get reference to display + Display display = activity.getWindowManager().getDefaultDisplay(); + Context context = webView.getContext(); + + // Use an ImageView to render the image because of its flexible scaling options. + splashImageView = new ImageView(context); + splashImageView.setImageResource(drawableId); + LayoutParams layoutParams = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT); + splashImageView.setLayoutParams(layoutParams); + + splashImageView.setMinimumHeight(display.getHeight()); + splashImageView.setMinimumWidth(display.getWidth()); + + // TODO: Use the background color of the webView's parent instead of using the preference. + splashImageView.setBackgroundColor(backgroundColor); + + if (maintainAspectRatio) { + // CENTER_CROP scale mode is equivalent to CSS "background-size:cover" + splashImageView.setScaleType(ImageView.ScaleType.CENTER_CROP); + } + else { + // FIT_XY scales image non-uniformly to fit into image view. + splashImageView.setScaleType(ImageView.ScaleType.FIT_XY); + } + + // Create and show the dialog + splashDialog = new Dialog(context, android.R.style.Theme_Translucent_NoTitleBar); + // check to see if the splash screen should be full screen + if ((activity.getWindow().getAttributes().flags & WindowManager.LayoutParams.FLAG_FULLSCREEN) + == WindowManager.LayoutParams.FLAG_FULLSCREEN) { + splashDialog.getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, + WindowManager.LayoutParams.FLAG_FULLSCREEN); + } + splashDialog.setContentView(splashImageView); + splashDialog.setCancelable(false); + splashDialog.show(); + + if (showSpinner) { + spinnerStart(); + } + + // Set Runnable to remove splash screen just in case + if (hideAfterDelay) { + final Handler handler = new Handler(); + handler.postDelayed(new Runnable() { + public void run() { + if (lastHideAfterDelay) { + removeSplashScreen(false); + } + } + }, effectiveSplashDuration); + } + } + }); + } + + // Show only spinner in the center of the screen + private void spinnerStart() { + activity.runOnUiThread(new Runnable() { + public void run() { + spinnerStop(); + + spinnerDialog = new ProgressDialog(webView.getContext()); + spinnerDialog.setOnCancelListener(new DialogInterface.OnCancelListener() { + public void onCancel(DialogInterface dialog) { + spinnerDialog = null; + } + }); + + spinnerDialog.setCancelable(false); + spinnerDialog.setIndeterminate(true); + + RelativeLayout centeredLayout = new RelativeLayout(activity); + centeredLayout.setGravity(Gravity.CENTER); + centeredLayout.setLayoutParams(new RelativeLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)); + + ProgressBar progressBar = new ProgressBar(webView.getContext()); + RelativeLayout.LayoutParams layoutParams = new RelativeLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); + layoutParams.addRule(RelativeLayout.CENTER_IN_PARENT, RelativeLayout.TRUE); + progressBar.setLayoutParams(layoutParams); + + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP) { + if(spinnerColor != null){ + int[][] states = new int[][] { + new int[] { android.R.attr.state_enabled}, // enabled + new int[] {-android.R.attr.state_enabled}, // disabled + new int[] {-android.R.attr.state_checked}, // unchecked + new int[] { android.R.attr.state_pressed} // pressed + }; + int progressBarColor = Color.parseColor(spinnerColor); + int[] colors = new int[] { + progressBarColor, + progressBarColor, + progressBarColor, + progressBarColor + }; + ColorStateList colorStateList = new ColorStateList(states, colors); + progressBar.setIndeterminateTintList(colorStateList); + } + } + + centeredLayout.addView(progressBar); + + spinnerDialog.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND); + spinnerDialog.getWindow().setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); + + spinnerDialog.show(); + spinnerDialog.setContentView(centeredLayout); + } + }); + } + + private void spinnerStop() { + activity.runOnUiThread(new Runnable() { + public void run() { + if (spinnerDialog != null && spinnerDialog.isShowing()) { + spinnerDialog.dismiss(); + spinnerDialog = null; + } + } + }); } } } diff --git a/framework/src/org/apache/cordova/SystemBarPlugin.java b/framework/src/org/apache/cordova/SystemBarPlugin.java index ebcc87daef..166341737f 100644 --- a/framework/src/org/apache/cordova/SystemBarPlugin.java +++ b/framework/src/org/apache/cordova/SystemBarPlugin.java @@ -50,12 +50,14 @@ public class SystemBarPlugin extends CordovaPlugin { private Integer overrideStatusBarBackgroundColor = null; private boolean canEdgeToEdge = false; + private String edgeToEdgeGlyphTheme = null; @Override protected void pluginInitialize() { context = cordova.getContext(); resources = context.getResources(); canEdgeToEdge = preferences.getBoolean("AndroidEdgeToEdge", false); + edgeToEdgeGlyphTheme = preferences.getString("EdgeToEdgeGlyphTheme", null); } @Override @@ -228,8 +230,20 @@ private void updateStatusBar(int bgColor) { } // Automatically set the font and icon color of the system bars based on background color. + // Determines whether to pass true to setAppearanceLightStatusBars(). + // Despite what the official Android docs say, passing true results in DARK glyphs + // (suitable for light backgrounds) and passing false results in LIGHT glyphs + // (suitable for dark backgrounds). + // + // In edge-to-edge mode the nav bar background is transparent, so luminance-based + // detection doesn't work. The EdgeToEdgeGlyphTheme preference lets developers + // explicitly choose "dark" or "light" glyphs to match their app's background. boolean isStatusBarBackgroundColorLight; - if(bgColor == Color.TRANSPARENT) { + if (canEdgeToEdge && "dark".equalsIgnoreCase(edgeToEdgeGlyphTheme)) { + isStatusBarBackgroundColorLight = true; + } else if (canEdgeToEdge && "light".equalsIgnoreCase(edgeToEdgeGlyphTheme)) { + isStatusBarBackgroundColorLight = false; + } else if(bgColor == Color.TRANSPARENT) { isStatusBarBackgroundColorLight = isColorLight(getUiModeColor()); } else { isStatusBarBackgroundColorLight = isColorLight(bgColor); diff --git a/lib/config/GradlePropertiesParser.js b/lib/config/GradlePropertiesParser.js index c61d9e4628..bc5d96b5b2 100644 --- a/lib/config/GradlePropertiesParser.js +++ b/lib/config/GradlePropertiesParser.js @@ -34,8 +34,7 @@ class GradlePropertiesParser { 'org.gradle.jvmargs': '-Xmx2048m', // Android X - 'android.useAndroidX': 'true', - 'android.enableJetifier': 'true' + 'android.useAndroidX': 'true' // Shaves another 100ms, but produces a "try at own risk" warning. Not worth it (yet): // 'org.gradle.parallel': 'true' diff --git a/lib/prepare.js b/lib/prepare.js index b30b985ce9..29a36b8afd 100644 --- a/lib/prepare.js +++ b/lib/prepare.js @@ -65,10 +65,10 @@ module.exports.prepare = function (cordovaProject, options) { // Update own www dir with project's www assets and plugins' assets and js-files return Promise.resolve(updateWww(cordovaProject, this.locations)) - .then(() => warnForDeprecatedSplashScreen(cordovaProject)) .then(() => updateProjectAccordingTo(self._config, self.locations)) .then(function () { updateIcons(cordovaProject, path.relative(cordovaProject.root, self.locations.res)); + updateSplashes(cordovaProject, path.relative(cordovaProject.root, self.locations.res)); updateFileResources(cordovaProject, path.relative(cordovaProject.root, self.locations.root)); }).then(function () { events.emit('verbose', 'Prepared android project successfully'); @@ -108,6 +108,7 @@ function getUserGradleConfig (configXml) { { xmlKey: 'GradlePluginKotlinVersion', gradleKey: 'KOTLIN_VERSION', type: String }, { xmlKey: 'AndroidXAppCompatVersion', gradleKey: 'ANDROIDX_APP_COMPAT_VERSION', type: String }, { xmlKey: 'AndroidXWebKitVersion', gradleKey: 'ANDROIDX_WEBKIT_VERSION', type: String }, + { xmlKey: 'AndroidXCoreSplashscreenVersion', gradleKey: 'ANDROIDX_CORE_SPLASHSCREEN_VERSION', type: String }, { xmlKey: 'GradlePluginGoogleServicesVersion', gradleKey: 'GRADLE_PLUGIN_GOOGLE_SERVICES_VERSION', type: String }, { xmlKey: 'GradlePluginGoogleServicesEnabled', gradleKey: 'IS_GRADLE_PLUGIN_GOOGLE_SERVICES_ENABLED', type: Boolean }, { xmlKey: 'GradlePluginKotlinEnabled', gradleKey: 'IS_GRADLE_PLUGIN_KOTLIN_ENABLED', type: Boolean }, @@ -176,6 +177,7 @@ module.exports.clean = function (options) { return Promise.resolve().then(function () { cleanWww(projectRoot, self.locations); cleanIcons(projectRoot, projectConfig, path.relative(projectRoot, self.locations.res)); + cleanSplashes(projectRoot, projectConfig, path.relative(projectRoot, self.locations.res)); cleanFileResources(projectRoot, projectConfig, path.relative(projectRoot, self.locations.root)); }); }; @@ -362,16 +364,6 @@ function updateProjectStrings (platformConfig, locations) { events.emit('verbose', 'Wrote out android application name "' + name + '" to ' + locations.strings); } -function warnForDeprecatedSplashScreen (cordovaProject) { - const hasOldSplashTags = ( - cordovaProject.projectConfig.doc.findall('./platform[@name="android"]/splash') || [] - ).length > 0; - - if (hasOldSplashTags) { - events.emit('warn', 'The "" tags were detected and are no longer supported. Please migrate to the "preference" tag "AndroidWindowSplashScreenAnimatedIcon".'); - } -} - /** * @param {ConfigParser} platformConfig A project's configuration that will * be used to update project @@ -648,6 +640,68 @@ function getAdaptiveImageResourcePath (resourcesDir, type, density, name, source return resourcePath; } +function makeSplashCleanupMap (projectRoot, resourcesDir) { + // Build an initial resource map that deletes all existing splash screens + const existingSplashPaths = glob.sync( + `${resourcesDir.replace(/\\/g, '/')}/drawable-*/screen.{png,9.png,webp,jpg,jpeg}`, + { cwd: projectRoot } + ); + return makeCleanResourceMap(existingSplashPaths); +} + +function updateSplashes (cordovaProject, platformResourcesDir) { + const resources = cordovaProject.projectConfig.getSplashScreens('android'); + + // if there are no "splash" elements in config.xml + if (resources.length === 0) { + events.emit('verbose', 'This app does not have splash screens defined'); + // We must not return here! + // If the user defines no splash screens, the cleanup map will cause any + // existing splash screen images (e.g. the defaults that we copy into a + // new app) to be removed from the app folder, which is what we want. + } + + // Build an initial resource map that deletes all existing splash screens + const resourceMap = makeSplashCleanupMap(cordovaProject.root, platformResourcesDir); + + let hadMdpi = false; + resources.forEach(function (resource) { + if (!resource.density) { + return; + } + if (resource.density === 'mdpi') { + hadMdpi = true; + } + const targetPath = getImageResourcePath( + platformResourcesDir, 'drawable', resource.density, 'screen', path.basename(resource.src)); + resourceMap[targetPath] = resource.src; + }); + + // There's no "default" drawable, so assume default == mdpi. + if (!hadMdpi && resources.defaultResource) { + const targetPath = getImageResourcePath( + platformResourcesDir, 'drawable', 'mdpi', 'screen', path.basename(resources.defaultResource.src)); + resourceMap[targetPath] = resources.defaultResource.src; + } + + events.emit('verbose', 'Updating splash screens at ' + platformResourcesDir); + FileUpdater.updatePaths( + resourceMap, { rootDir: cordovaProject.root }, logFileOp); +} + +function cleanSplashes (projectRoot, projectConfig, platformResourcesDir) { + const resources = projectConfig.getSplashScreens('android'); + if (resources.length > 0) { + const resourceMap = makeSplashCleanupMap(projectRoot, platformResourcesDir); + + events.emit('verbose', 'Cleaning splash screens at ' + platformResourcesDir); + + // No source paths are specified in the map, so updatePaths() will delete the target files. + FileUpdater.updatePaths( + resourceMap, { rootDir: projectRoot, all: true }, logFileOp); + } +} + function updateIcons (cordovaProject, platformResourcesDir) { const icons = cordovaProject.projectConfig.getIcons('android'); @@ -729,7 +783,7 @@ function updateIconResourceForAdaptive (preparedIcons, resourceMap, platformReso const android_icons = preparedIcons.android_icons; const default_icon = preparedIcons.default_icon; - // The source paths for icons are relative to + // The source paths for icons and splashes are relative to // project's config.xml location, so we use it as base path. let background; let foreground; @@ -888,7 +942,7 @@ function updateIconResourceForLegacy (preparedIcons, resourceMap, platformResour const android_icons = preparedIcons.android_icons; const default_icon = preparedIcons.default_icon; - // The source paths for icons are relative to + // The source paths for icons and splashes are relative to // project's config.xml location, so we use it as base path. for (const density in android_icons) { const targetPath = getImageResourcePath(platformResourcesDir, 'mipmap', density, 'ic_launcher', path.basename(android_icons[density].src)); @@ -1030,6 +1084,16 @@ function mapImageResources (rootDir, subDir, type, resourceName) { return pathMap; } +/** Returns resource map that deletes all given paths */ +function makeCleanResourceMap (resourcePaths) { + const pathMap = {}; + resourcePaths.map(path.normalize) + .forEach(resourcePath => { + pathMap[resourcePath] = null; + }); + return pathMap; +} + function updateFileResources (cordovaProject, platformDir) { const files = cordovaProject.projectConfig.getFileResources('android'); diff --git a/spec/unit/prepare.spec.js b/spec/unit/prepare.spec.js index 01bf70f90c..dd1d9984db 100644 --- a/spec/unit/prepare.spec.js +++ b/spec/unit/prepare.spec.js @@ -87,6 +87,18 @@ function mockGetIconItem (data) { }, data); } +/** + * Create a mock item from the getSplashScreen collection with the supplied updated data. + * + * @param {Object} data Changes to apply to the mock getSplashScreen item + */ +function mockGetSplashScreenItem (data) { + return Object.assign({}, { + src: undefined, + density: undefined + }, data); +} + describe('prepare', () => { // Rewire let prepare; @@ -856,9 +868,8 @@ describe('prepare', () => { prepare.__set__('updateWww', jasmine.createSpy()); prepare.__set__('updateProjectAccordingTo', jasmine.createSpy('updateProjectAccordingTo') .and.returnValue(Promise.resolve())); - prepare.__set__('warnForDeprecatedSplashScreen', jasmine.createSpy('warnForDeprecatedSplashScreen') - .and.returnValue(Promise.resolve())); prepare.__set__('updateIcons', jasmine.createSpy('updateIcons').and.returnValue(Promise.resolve())); + prepare.__set__('updateSplashes', jasmine.createSpy('updateSplashes').and.returnValue(Promise.resolve())); prepare.__set__('updateFileResources', jasmine.createSpy('updateFileResources').and.returnValue(Promise.resolve())); prepare.__set__('updateConfigFilesFrom', jasmine.createSpy('updateConfigFilesFrom') @@ -951,8 +962,8 @@ describe('prepare', () => { prepare.__set__('updateWww', jasmine.createSpy('updateWww')); prepare.__set__('updateIcons', jasmine.createSpy('updateIcons').and.returnValue(Promise.resolve())); prepare.__set__('updateProjectTheme', jasmine.createSpy('updateProjectTheme')); - prepare.__set__('warnForDeprecatedSplashScreen', jasmine.createSpy('warnForDeprecatedSplashScreen') - .and.returnValue(Promise.resolve())); + prepare.__set__('updateProjectSplashScreen', jasmine.createSpy('updateProjectSplashScreen')); + prepare.__set__('updateSplashes', jasmine.createSpy('updateSplashes').and.returnValue(Promise.resolve())); prepare.__set__('updateFileResources', jasmine.createSpy('updateFileResources').and.returnValue(Promise.resolve())); prepare.__set__('updateConfigFilesFrom', jasmine.createSpy('updateConfigFilesFrom') @@ -1038,4 +1049,110 @@ describe('prepare', () => { }); }); }); + + describe('updateSplashes method', function () { + // Mock Data + let cordovaProject; + let platformResourcesDir; + + beforeEach(function () { + cordovaProject = { + root: '/mock', + projectConfig: { + path: '/mock/config.xml', + cdvNamespacePrefix: 'cdv' + }, + locations: { + plugins: '/mock/plugins', + www: '/mock/www' + } + }; + platformResourcesDir = PATH_RESOURCE; + + // mocking initial responses for mapImageResources + prepare.__set__('makeSplashCleanupMap', (rootDir, resourcesDir) => ({ + [path.join(resourcesDir, 'drawable-mdpi/screen.png')]: null, + [path.join(resourcesDir, 'drawable-mdpi/screen.webp')]: null + })); + }); + + it('Test#001 : Should detect no defined splash screens.', function () { + const updateSplashes = prepare.__get__('updateSplashes'); + + // mock data. + cordovaProject.projectConfig.getSplashScreens = function (platform) { + return []; + }; + + updateSplashes(cordovaProject, platformResourcesDir); + + // The emit was called + expect(emitSpy).toHaveBeenCalled(); + + // The emit message was. + const actual = emitSpy.calls.argsFor(0)[1]; + const expected = 'This app does not have splash screens defined'; + expect(actual).toEqual(expected); + }); + + it('Test#02 : Should update splash png icon.', function () { + const updateSplashes = prepare.__get__('updateSplashes'); + + // mock data. + cordovaProject.projectConfig.getSplashScreens = function (platform) { + return [mockGetSplashScreenItem({ + density: 'mdpi', + src: 'res/splash/android/mdpi-screen.png' + })]; + }; + + updateSplashes(cordovaProject, platformResourcesDir); + + // The emit was called + expect(emitSpy).toHaveBeenCalled(); + + // The emit message was. + const actual = emitSpy.calls.argsFor(0)[1]; + const expected = 'Updating splash screens at ' + PATH_RESOURCE; + expect(actual).toEqual(expected); + + const actualResourceMap = updatePathsSpy.calls.argsFor(0)[0]; + const expectedResourceMap = {}; + expectedResourceMap[path.join(PATH_RESOURCE, 'drawable-mdpi', 'screen.png')] = 'res/splash/android/mdpi-screen.png'; + expectedResourceMap[path.join(PATH_RESOURCE, 'drawable-mdpi', 'screen.webp')] = null; + + expect(actualResourceMap).toEqual(expectedResourceMap); + }); + + it('Test#03 : Should update splash webp icon.', function () { + const updateSplashes = prepare.__get__('updateSplashes'); + + // mock data. + cordovaProject.projectConfig.getSplashScreens = function (platform) { + return [mockGetSplashScreenItem({ + density: 'mdpi', + src: 'res/splash/android/mdpi-screen.webp' + })]; + }; + + // Creating Spies + updateSplashes(cordovaProject, platformResourcesDir); + + // The emit was called + expect(emitSpy).toHaveBeenCalled(); + + // The emit message was. + const actual = emitSpy.calls.argsFor(0)[1]; + const expected = 'Updating splash screens at ' + PATH_RESOURCE; + expect(actual).toEqual(expected); + + const actualResourceMap = updatePathsSpy.calls.argsFor(0)[0]; + + const expectedResourceMap = {}; + expectedResourceMap[path.join(PATH_RESOURCE, 'drawable-mdpi', 'screen.webp')] = 'res/splash/android/mdpi-screen.webp'; + expectedResourceMap[path.join(PATH_RESOURCE, 'drawable-mdpi', 'screen.png')] = null; + + expect(actualResourceMap).toEqual(expectedResourceMap); + }); + }); }); diff --git a/templates/project/app/build.gradle b/templates/project/app/build.gradle index 8bfae15eba..611f60634c 100644 --- a/templates/project/app/build.gradle +++ b/templates/project/app/build.gradle @@ -21,11 +21,6 @@ apply plugin: 'com.android.application' if (cordovaConfig.IS_GRADLE_PLUGIN_KOTLIN_ENABLED) { apply plugin: 'kotlin-android' - - if(!cdvHelpers.isVersionGreaterThanEqual(cordovaConfig.KOTLIN_VERSION, '1.8.0')) { - println "Kotlin version < 1.8.0 detected. Applying kotlin-android-extensions plugin." - apply plugin: 'kotlin-android-extensions' - } } buildscript { @@ -75,6 +70,8 @@ allprojects { } task wrapper(type: Wrapper) { + // Apparently required by Gradle >= 8.6, despite being the default + validateDistributionUrl = true gradleVersion = cordovaConfig.GRADLE_VERSION } @@ -176,6 +173,7 @@ task cdvPrintProps { println('computedVersionCode=' + android.defaultConfig.versionCode) println('cdvAndroidXAppCompatVersion=' + cdvAndroidXAppCompatVersion) println('cdvAndroidXWebKitVersion=' + cdvAndroidXWebKitVersion) + println('cdvAndroidXCoreSplashscreenVersion=' + cdvAndroidXCoreSplashscreenVersion) android.productFlavors.each { flavor -> println('computed' + flavor.name.capitalize() + 'VersionCode=' + flavor.versionCode) } diff --git a/templates/project/res/drawable-land-hdpi/screen.png b/templates/project/res/drawable-land-hdpi/screen.png new file mode 100644 index 0000000000..1600863909 Binary files /dev/null and b/templates/project/res/drawable-land-hdpi/screen.png differ diff --git a/templates/project/res/drawable-land-ldpi/screen.png b/templates/project/res/drawable-land-ldpi/screen.png new file mode 100644 index 0000000000..f7ad3cfad7 Binary files /dev/null and b/templates/project/res/drawable-land-ldpi/screen.png differ diff --git a/templates/project/res/drawable-land-mdpi/screen.png b/templates/project/res/drawable-land-mdpi/screen.png new file mode 100644 index 0000000000..b6243130f8 Binary files /dev/null and b/templates/project/res/drawable-land-mdpi/screen.png differ diff --git a/templates/project/res/drawable-land-xhdpi/screen.png b/templates/project/res/drawable-land-xhdpi/screen.png new file mode 100644 index 0000000000..9720f415c3 Binary files /dev/null and b/templates/project/res/drawable-land-xhdpi/screen.png differ diff --git a/templates/project/res/drawable-land-xxhdpi/screen.png b/templates/project/res/drawable-land-xxhdpi/screen.png new file mode 100644 index 0000000000..80d6b4709f Binary files /dev/null and b/templates/project/res/drawable-land-xxhdpi/screen.png differ diff --git a/templates/project/res/drawable-land-xxxhdpi/screen.png b/templates/project/res/drawable-land-xxxhdpi/screen.png new file mode 100644 index 0000000000..84c5a2ba88 Binary files /dev/null and b/templates/project/res/drawable-land-xxxhdpi/screen.png differ diff --git a/templates/project/res/drawable-port-hdpi/screen.png b/templates/project/res/drawable-port-hdpi/screen.png new file mode 100644 index 0000000000..b2f60af643 Binary files /dev/null and b/templates/project/res/drawable-port-hdpi/screen.png differ diff --git a/templates/project/res/drawable-port-ldpi/screen.png b/templates/project/res/drawable-port-ldpi/screen.png new file mode 100644 index 0000000000..4b2abbb1a9 Binary files /dev/null and b/templates/project/res/drawable-port-ldpi/screen.png differ diff --git a/templates/project/res/drawable-port-mdpi/screen.png b/templates/project/res/drawable-port-mdpi/screen.png new file mode 100644 index 0000000000..1f1ae9d456 Binary files /dev/null and b/templates/project/res/drawable-port-mdpi/screen.png differ diff --git a/templates/project/res/drawable-port-xhdpi/screen.png b/templates/project/res/drawable-port-xhdpi/screen.png new file mode 100644 index 0000000000..690c57eb25 Binary files /dev/null and b/templates/project/res/drawable-port-xhdpi/screen.png differ diff --git a/templates/project/res/drawable-port-xxhdpi/screen.png b/templates/project/res/drawable-port-xxhdpi/screen.png new file mode 100644 index 0000000000..214f0a0112 Binary files /dev/null and b/templates/project/res/drawable-port-xxhdpi/screen.png differ diff --git a/templates/project/res/drawable-port-xxxhdpi/screen.png b/templates/project/res/drawable-port-xxxhdpi/screen.png new file mode 100644 index 0000000000..c4ef7723bd Binary files /dev/null and b/templates/project/res/drawable-port-xxxhdpi/screen.png differ diff --git a/test/androidx/gradle.properties b/test/androidx/gradle.properties index 7b2379018f..bfab5da496 100644 --- a/test/androidx/gradle.properties +++ b/test/androidx/gradle.properties @@ -28,7 +28,6 @@ # The setting is particularly useful for tweaking memory settings. org.gradle.jvmargs=-Xmx1536m android.useAndroidX=true -android.enableJetifier=true # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit