diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f0bf006ee..3e62719bc0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: strategy: matrix: node-version: [16.x, 18.x, 20.x, 22.x] - os: [ubuntu-latest, windows-latest, macos-latest] + os: [ubuntu-latest, macos-latest] steps: - uses: actions/checkout@v4 @@ -36,7 +36,8 @@ jobs: with: node-version: ${{ matrix.node-version }} - - uses: actions/setup-java@v4 + - name: set up JDK 17 + uses: actions/setup-java@v4 with: distribution: 'temurin' java-version: '17' @@ -68,12 +69,3 @@ jobs: npm t env: CI: true - - - uses: github/codeql-action/analyze@v3 - - - uses: codecov/codecov-action@v4 - 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 63e77854ae..adac610282 100644 --- a/cordova-js-src/plugin/android/splashscreen.js +++ b/cordova-js-src/plugin/android/splashscreen.js @@ -23,7 +23,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/cdv-gradle-config-defaults.json b/framework/cdv-gradle-config-defaults.json index 46e6513dd8..00d92bd817 100644 --- a/framework/cdv-gradle-config-defaults.json +++ b/framework/cdv-gradle-config-defaults.json @@ -1,5 +1,5 @@ { - "MIN_SDK_VERSION": 24, + "MIN_SDK_VERSION": 28, "SDK_VERSION": 34, "COMPILE_SDK_VERSION": null, "GRADLE_VERSION": "8.7", @@ -7,11 +7,11 @@ "AGP_VERSION": "8.3.0", "KOTLIN_VERSION": "1.9.24", "ANDROIDX_APP_COMPAT_VERSION": "1.6.1", - "ANDROIDX_WEBKIT_VERSION": "1.6.0", - "ANDROIDX_CORE_SPLASHSCREEN_VERSION": "1.0.0", - "GRADLE_PLUGIN_GOOGLE_SERVICES_VERSION": "4.3.15", + "ANDROIDX_WEBKIT_VERSION": "1.8.0", + "ANDROIDX_CORE_SPLASHSCREEN_VERSION": "1.0.1", + "GRADLE_PLUGIN_GOOGLE_SERVICES_VERSION": "4.4.0", "IS_GRADLE_PLUGIN_GOOGLE_SERVICES_ENABLED": false, - "IS_GRADLE_PLUGIN_KOTLIN_ENABLED": false, + "IS_GRADLE_PLUGIN_KOTLIN_ENABLED": true, "PACKAGE_NAMESPACE": "io.cordova.helloCordova", "JAVA_SOURCE_COMPATIBILITY": 8, "JAVA_TARGET_COMPATIBILITY": 8, diff --git a/framework/cordova.gradle b/framework/cordova.gradle index 9b97839895..ced48caaa0 100644 --- a/framework/cordova.gradle +++ b/framework/cordova.gradle @@ -172,6 +172,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 060ebf7aef..afc99f438d 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 325e9e9dd6..32fa628a45 100755 --- a/framework/src/org/apache/cordova/CordovaActivity.java +++ b/framework/src/org/apache/cordova/CordovaActivity.java @@ -186,6 +186,7 @@ protected void createViews() { appView.getView().setLayoutParams(new FrameLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + appView.getView().setFilterTouchesWhenObscured(preferences.getBoolean("FilterTouchesWhenObscured", true)); setContentView(appView.getView()); diff --git a/framework/src/org/apache/cordova/CordovaWebViewImpl.java b/framework/src/org/apache/cordova/CordovaWebViewImpl.java index 4d5221e27b..3a805fd880 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 425b13f9bc..4a45269683 100644 --- a/framework/src/org/apache/cordova/SplashScreenPlugin.java +++ b/framework/src/org/apache/cordova/SplashScreenPlugin.java @@ -22,9 +22,29 @@ 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; @@ -33,71 +53,99 @@ Licensed to the Apache Software Foundation (ASF) under one 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; - - // Config preference values - /** - * @param boolean autoHide to auto splash screen (default=true) - */ - private boolean autoHide; - /** - * @param int delayTime in milliseconds (default=-1) - */ - private int delayTime; - /** - * @param int fade to fade out splash screen (default=true) - */ - private boolean isFadeEnabled; - /** - * @param int fadeDuration fade out duration in milliseconds (default=500) - */ - private int fadeDuration; - - // Internal variables - /** - * @param boolean keepOnScreen flag to determine if the splash screen remains visible. - */ - private boolean keepOnScreen = true; + 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; + + 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); + boolean 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)); + + } 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)); + + // 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,63 +156,515 @@ 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; + } + + @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; - case "onPageFinished": - attemptCloseOnPageFinished(); - break; + 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; + + // Configuration variables + private boolean autoHide; + private int delayTime; + private boolean isFadeEnabled; + private int fadeDuration; + + // Internal variables + private boolean keepOnScreen; - // 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); + public AndroidSplashScreenBehaviour(Context context, boolean autoHide, int delayTime, boolean isFadeEnabled, int fadeDuration) { + // Context + this.context = context; + // Configuration variables + this.autoHide = autoHide; + this.delayTime = delayTime; + this.isFadeEnabled = isFadeEnabled; + this.fadeDuration = fadeDuration; + // Internal variables + this.keepOnScreen = true; } - // 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(); + } + }).start(); + } + }); + } + } + + 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(Animator animation) { - super.onAnimationEnd(animation); - splashScreenViewProvider.remove(); + 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; + } } - }).start(); + + @Override + public void onAnimationRepeat(Animation animation) { + } + }); + } else { + spinnerStop(); + splashDialog.dismiss(); + splashDialog = null; + splashImageView = 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/lib/config/GradlePropertiesParser.js b/lib/config/GradlePropertiesParser.js index 9189a91b4b..f51df40c6c 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 284904c429..493f2789f4 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 }, @@ -175,6 +176,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)); }); }; @@ -361,16 +363,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 @@ -658,6 +650,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'); @@ -739,7 +793,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; @@ -898,7 +952,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)); @@ -1040,6 +1094,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/create.spec.js b/spec/unit/create.spec.js index 8c9c4304bd..bc0d20553c 100644 --- a/spec/unit/create.spec.js +++ b/spec/unit/create.spec.js @@ -284,10 +284,35 @@ describe('create', function () { }); it('should interpolate the escaped project name into strings.xml', () => { + var passed = true; config_mock.name.and.returnValue(''); - return create.create(project_path, config_mock, {}, events_mock).then(() => { + passed = passed && create.create(project_path, config_mock, {}, events_mock).then(() => { expect(utils.replaceFileContents).toHaveBeenCalledWith(path.join(app_path, 'res', 'values', 'strings.xml'), /__NAME__/, '<Incredible&App>'); }); + config_mock.name.and.returnValue('& 12XPTO & W'); + passed = passed && create.create(project_path, config_mock, {}, events_mock).then(() => { + expect(utils.replaceFileContents).toHaveBeenCalledWith(path.join(app_path, 'res', 'values', 'strings.xml'), /__NAME__/, '& 12XPTO & W'); + }); + config_mock.name.and.returnValue('Strange $ Combinaned # App &'); + passed = passed && create.create(project_path, config_mock, {}, events_mock).then(() => { + expect(utils.replaceFileContents).toHaveBeenCalledWith(path.join(app_path, 'res', 'values', 'strings.xml'), /__NAME__/, 'Strange $ Combinaned # App &'); + }); + config_mock.name.and.returnValue('$KUH% I*& $)OFNlkfn$'); + passed = passed && create.create(project_path, config_mock, {}, events_mock).then(() => { + expect(utils.replaceFileContents).toHaveBeenCalledWith(path.join(app_path, 'res', 'values', 'strings.xml'), /__NAME__/, '$KUH% I*& $)OFNlkfn$'); + }); + config_mock.name.and.returnValue('&B&B'); + passed = passed && create.create(project_path, config_mock, {}, events_mock).then(() => { + expect(utils.replaceFileContents).toHaveBeenCalledWith(path.join(app_path, 'res', 'values', 'strings.xml'), /__NAME__/, '&B&B'); + }); + return passed; + }); + + it('should interpolate the project name with arabic chars into strings.xml', () => { + config_mock.name.and.returnValue('غظضذخثتشرقصفعسنملكيطحزوهدبأ'); + return create.create(project_path, config_mock, {}, events_mock).then(() => { + expect(utils.replaceFileContents).toHaveBeenCalledWith(path.join(app_path, 'res', 'values', 'strings.xml'), /__NAME__/, 'غظضذخثتشرقصفعسنملكيطحزوهدبأ'); + }); }); it('should copy template scripts into generated project', () => { diff --git a/spec/unit/prepare.spec.js b/spec/unit/prepare.spec.js index bdc65b4f71..54d453f393 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,7 @@ describe('prepare', () => { prepare.__set__('updateWww', jasmine.createSpy('updateWww')); prepare.__set__('updateIcons', jasmine.createSpy('updateIcons').and.returnValue(Promise.resolve())); prepare.__set__('updateProjectSplashScreen', jasmine.createSpy('updateProjectSplashScreen')); - prepare.__set__('warnForDeprecatedSplashScreen', jasmine.createSpy('warnForDeprecatedSplashScreen') - .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') @@ -1039,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 19f97c21fb..933b8bbcb8 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) } @@ -291,6 +289,12 @@ android { } } + if (cordovaConfig.IS_GRADLE_PLUGIN_KOTLIN_ENABLED) { + kotlinOptions { + jvmTarget = '1.8' + } + } + if (cdvReleaseSigningPropertiesFile) { signingConfigs { release { 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 060ebf7aef..afc99f438d 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