// Copyright 2015 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. package org.chromium.chrome.browser.webapps; import android.content.Intent; import android.graphics.Bitmap; import android.graphics.Color; import android.graphics.drawable.Drawable; import android.net.Uri; import android.os.Bundle; import android.os.StrictMode; import android.os.SystemClock; import android.text.TextUtils; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.FrameLayout; import android.widget.ImageView; import android.widget.TextView; import org.chromium.base.ActivityState; import org.chromium.base.ApiCompatibilityUtils; import org.chromium.base.ApplicationStatus; import org.chromium.base.Log; import org.chromium.base.VisibleForTesting; import org.chromium.base.metrics.RecordHistogram; import org.chromium.blink_public.platform.WebDisplayMode; import org.chromium.chrome.R; import org.chromium.chrome.browser.TabState; import org.chromium.chrome.browser.document.DocumentUtils; import org.chromium.chrome.browser.fullscreen.ChromeFullscreenManager; import org.chromium.chrome.browser.metrics.WebappUma; import org.chromium.chrome.browser.tab.EmptyTabObserver; import org.chromium.chrome.browser.tab.Tab; import org.chromium.chrome.browser.tab.TabDelegateFactory; import org.chromium.chrome.browser.tab.TabObserver; import org.chromium.chrome.browser.util.ColorUtils; import org.chromium.chrome.browser.util.UrlUtilities; import org.chromium.chrome.browser.widget.ControlContainer; import org.chromium.content.browser.ScreenOrientationProvider; import org.chromium.content_public.browser.LoadUrlParams; import org.chromium.net.NetworkChangeNotifier; import org.chromium.ui.base.PageTransition; import java.io.File; import java.util.concurrent.TimeUnit; /** * Displays a webapp in a nearly UI-less Chrome (InfoBars still appear). */ public class WebappActivity extends FullScreenActivity { public static final String WEBAPP_SCHEME = "webapp"; private static final String TAG = "WebappActivity"; private static final long MS_BEFORE_NAVIGATING_BACK_FROM_INTERSTITIAL = 1000; private final WebappDirectoryManager mDirectoryManager; protected WebappInfo mWebappInfo; private boolean mOldWebappCleanupStarted; private ViewGroup mSplashScreen; private WebappUrlBar mUrlBar; private boolean mIsInitialized; private Integer mBrandColor; private WebappUma mWebappUma; private Bitmap mLargestFavicon; /** * Construct all the variables that shouldn't change. We do it here both to clarify when the * objects are created and to ensure that they exist throughout the parallelized initialization * of the WebappActivity. */ public WebappActivity() { mWebappInfo = WebappInfo.createEmpty(); mDirectoryManager = new WebappDirectoryManager(); mWebappUma = new WebappUma(); } @Override protected void onNewIntent(Intent intent) { if (intent == null) return; super.onNewIntent(intent); WebappInfo newWebappInfo = WebappInfo.create(intent); if (newWebappInfo == null) { Log.e(TAG, "Failed to parse new Intent: " + intent); finish(); } else if (!TextUtils.equals(mWebappInfo.id(), newWebappInfo.id())) { mWebappInfo = newWebappInfo; resetSavedInstanceState(); if (mIsInitialized) initializeUI(null); // TODO(dominickn): send the web app into fullscreen if mDisplayMode is // WebDisplayMode.Fullscreen. See crbug.com/581522 } } protected boolean isInitialized() { return mIsInitialized; } private void initializeUI(Bundle savedInstanceState) { // We do not load URL when restoring from saved instance states. if (savedInstanceState == null && mWebappInfo.isInitialized()) { if (TextUtils.isEmpty(getActivityTab().getUrl())) { getActivityTab().loadUrl(new LoadUrlParams( mWebappInfo.uri().toString(), PageTransition.AUTO_TOPLEVEL)); } } else { if (NetworkChangeNotifier.isOnline()) getActivityTab().reloadIgnoringCache(); } getActivityTab().addObserver(createTabObserver()); getActivityTab().getTabWebContentsDelegateAndroid().setDisplayMode( WebDisplayMode.Standalone); // TODO(dominickn): send the web app into fullscreen if mDisplayMode is // WebDisplayMode.Fullscreen. See crbug.com/581522 } @Override public void preInflationStartup() { WebappInfo info = WebappInfo.create(getIntent()); if (info != null) mWebappInfo = info; ScreenOrientationProvider.lockOrientation((byte) mWebappInfo.orientation(), this); super.preInflationStartup(); } @Override public void finishNativeInitialization() { if (!mWebappInfo.isInitialized()) finish(); super.finishNativeInitialization(); initializeUI(getSavedInstanceState()); mIsInitialized = true; } @Override protected void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); if (getActivityTab() != null) { outState.putInt(BUNDLE_TAB_ID, getActivityTab().getId()); outState.putString(BUNDLE_TAB_URL, getActivityTab().getUrl()); } } @Override public void onStartWithNative() { super.onStartWithNative(); mDirectoryManager.cleanUpDirectories(this, getActivityId()); } @Override public void onStopWithNative() { super.onStopWithNative(); mDirectoryManager.cancelCleanup(); if (getActivityTab() != null) saveState(getActivityDirectory()); if (getFullscreenManager() != null) { getFullscreenManager().setPersistentFullscreenMode(false); } } /** * Saves the tab data out to a file. */ void saveState(File activityDirectory) { String tabFileName = TabState.getTabStateFilename(getActivityTab().getId(), false); File tabFile = new File(activityDirectory, tabFileName); // Temporarily allowing disk access while fixing. TODO: http://crbug.com/525781 StrictMode.ThreadPolicy oldPolicy = StrictMode.allowThreadDiskReads(); StrictMode.allowThreadDiskWrites(); try { long time = SystemClock.elapsedRealtime(); TabState.saveState(tabFile, getActivityTab().getState(), false); RecordHistogram.recordTimesHistogram("Android.StrictMode.WebappSaveState", SystemClock.elapsedRealtime() - time, TimeUnit.MILLISECONDS); } finally { StrictMode.setThreadPolicy(oldPolicy); } } @Override public void onResume() { if (!isFinishing()) { if (getIntent() != null) { // Avoid situations where Android starts two Activities with the same data. DocumentUtils.finishOtherTasksWithData(getIntent().getData(), getTaskId()); } updateTaskDescription(); } super.onResume(); // Kick off the old web app cleanup (if we haven't already) now that we have queued the // current web app's storage to be opened. if (!mOldWebappCleanupStarted) { WebappRegistry.unregisterOldWebapps(System.currentTimeMillis()); mOldWebappCleanupStarted = true; } } @Override public void onResumeWithNative() { super.onResumeWithNative(); mWebappUma.commitMetrics(); } @Override protected int getControlContainerLayoutId() { return R.layout.webapp_control_container; } @Override public void postInflationStartup() { initializeWebappData(); super.postInflationStartup(); WebappControlContainer controlContainer = (WebappControlContainer) findViewById(R.id.control_container); mUrlBar = (WebappUrlBar) controlContainer.findViewById(R.id.webapp_url_bar); } /** * @return Structure containing data about the webapp currently displayed. * The return value should not be cached. */ WebappInfo getWebappInfo() { return mWebappInfo; } /** * @return A string containing the scope of the webapp opened in this activity. */ public String getWebappScope() { return mWebappInfo.scopeUri().toString(); } private void initializeWebappData() { final int backgroundColor = ColorUtils.getOpaqueColor(mWebappInfo.backgroundColor( ApiCompatibilityUtils.getColor(getResources(), R.color.webapp_default_bg))); mSplashScreen = new FrameLayout(this); mSplashScreen.setBackgroundColor(backgroundColor); ViewGroup contentView = (ViewGroup) findViewById(android.R.id.content); contentView.addView(mSplashScreen); mWebappUma.splashscreenVisible(); mWebappUma.recordSplashscreenBackgroundColor(mWebappInfo.hasValidBackgroundColor() ? WebappUma.SPLASHSCREEN_COLOR_STATUS_CUSTOM : WebappUma.SPLASHSCREEN_COLOR_STATUS_DEFAULT); mWebappUma.recordSplashscreenThemeColor(mWebappInfo.hasValidThemeColor() ? WebappUma.SPLASHSCREEN_COLOR_STATUS_CUSTOM : WebappUma.SPLASHSCREEN_COLOR_STATUS_DEFAULT); initializeSplashScreenWidgets(backgroundColor); } protected void initializeSplashScreenWidgets(final int backgroundColor) { WebappRegistry.getWebappDataStorage( mWebappInfo.id(), new WebappRegistry.FetchWebappDataStorageCallback() { @Override public void onWebappDataStorageRetrieved(WebappDataStorage storage) { if (storage == null) { onStorageIsNull(backgroundColor); return; } updateStorage(storage); // Retrieve the splash image if it exists. storage.getSplashScreenImage(new WebappDataStorage.FetchCallback<Bitmap>() { @Override public void onDataRetrieved(Bitmap splashImage) { initializeSplashScreenWidgets(backgroundColor, splashImage); } }); } }); } protected void onStorageIsNull(int backgroundColor) {} protected void updateStorage(WebappDataStorage storage) { // The information in the WebappDataStorage may have been purged by the // user clearing their history or not launching the web app recently. // Restore the data if necessary from the intent. storage.updateFromShortcutIntent(getIntent()); // A recent last used time is the indicator that the web app is still // present on the home screen, and enables sources such as notifications to // launch web apps. Thus, we do not update the last used time when the web // app is not directly launched from the home screen, as this interferes // with the heuristic. if (mWebappInfo.isLaunchedFromHomescreen()) { storage.updateLastUsedTime(); } } protected void initializeSplashScreenWidgets(int backgroundColor, Bitmap splashImage) { Bitmap displayIcon = splashImage == null ? mWebappInfo.icon() : splashImage; int minimiumSizeThreshold = getResources().getDimensionPixelSize( R.dimen.webapp_splash_image_size_minimum); int bigThreshold = getResources().getDimensionPixelSize( R.dimen.webapp_splash_image_size_threshold); // Inflate the correct layout for the image. int layoutId; if (displayIcon == null || displayIcon.getWidth() < minimiumSizeThreshold || (displayIcon == mWebappInfo.icon() && mWebappInfo.isIconGenerated())) { mWebappUma.recordSplashscreenIconType(WebappUma.SPLASHSCREEN_ICON_TYPE_NONE); layoutId = R.layout.webapp_splash_screen_no_icon; } else { // The size of the splash screen image determines which layout to use. boolean isUsingSmallSplashImage = displayIcon.getWidth() <= bigThreshold || displayIcon.getHeight() <= bigThreshold; if (isUsingSmallSplashImage) { layoutId = R.layout.webapp_splash_screen_small; } else { layoutId = R.layout.webapp_splash_screen_large; } // Record stats about the splash screen. int splashScreenIconType; if (splashImage == null) { splashScreenIconType = WebappUma.SPLASHSCREEN_ICON_TYPE_FALLBACK; } else if (isUsingSmallSplashImage) { splashScreenIconType = WebappUma.SPLASHSCREEN_ICON_TYPE_CUSTOM_SMALL; } else { splashScreenIconType = WebappUma.SPLASHSCREEN_ICON_TYPE_CUSTOM; } mWebappUma.recordSplashscreenIconType(splashScreenIconType); mWebappUma.recordSplashscreenIconSize( Math.round(displayIcon.getWidth() / getResources().getDisplayMetrics().density)); } ViewGroup subLayout = (ViewGroup) LayoutInflater.from(WebappActivity.this) .inflate(layoutId, mSplashScreen, true); // Set up the elements of the splash screen. TextView appNameView = (TextView) subLayout.findViewById( R.id.webapp_splash_screen_name); ImageView splashIconView = (ImageView) subLayout.findViewById( R.id.webapp_splash_screen_icon); appNameView.setText(mWebappInfo.name()); if (splashIconView != null) splashIconView.setImageBitmap(displayIcon); if (ColorUtils.shouldUseLightForegroundOnBackground(backgroundColor)) { appNameView.setTextColor(ApiCompatibilityUtils.getColor(getResources(), R.color.webapp_splash_title_light)); } } private void updateUrlBar() { Tab tab = getActivityTab(); if (tab == null || mUrlBar == null) return; mUrlBar.update(tab.getUrl(), tab.getSecurityLevel()); } private boolean isWebappDomain() { return UrlUtilities.sameDomainOrHost( getActivityTab().getUrl(), getWebappInfo().uri().toString(), true); } protected TabObserver createTabObserver() { return new EmptyTabObserver() { @Override public void onSSLStateUpdated(Tab tab) { updateUrlBar(); } @Override public void onDidStartProvisionalLoadForFrame( Tab tab, long frameId, long parentFrameId, boolean isMainFrame, String validatedUrl, boolean isErrorPage, boolean isIframeSrcdoc) { if (isMainFrame) updateUrlBar(); } @Override public void onDidChangeThemeColor(Tab tab, int color) { if (!isWebappDomain()) return; mBrandColor = color; updateTaskDescription(); } @Override public void onTitleUpdated(Tab tab) { if (!isWebappDomain()) return; updateTaskDescription(); } @Override public void onFaviconUpdated(Tab tab, Bitmap icon) { if (!isWebappDomain()) return; // No need to cache the favicon if there is an icon declared in app manifest. if (mWebappInfo.icon() != null) return; if (icon == null) return; if (mLargestFavicon == null || icon.getWidth() > mLargestFavicon.getWidth() || icon.getHeight() > mLargestFavicon.getHeight()) { mLargestFavicon = icon; updateTaskDescription(); } } @Override public void onDidNavigateMainFrame(Tab tab, String url, String baseUrl, boolean isNavigationToDifferentPage, boolean isNavigationInPage, int statusCode) { updateUrlBar(); } @Override public void onDidAttachInterstitialPage(Tab tab) { updateUrlBar(); int state = ApplicationStatus.getStateForActivity(WebappActivity.this); if (state == ActivityState.PAUSED || state == ActivityState.STOPPED || state == ActivityState.DESTROYED) { return; } // Kick the interstitial navigation to Chrome. Intent intent = new Intent( Intent.ACTION_VIEW, Uri.parse(getActivityTab().getUrl())); intent.setPackage(getPackageName()); intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); startActivity(intent); // Pretend like the navigation never happened. We delay so that this happens while // the Activity is in the background. mHandler.postDelayed(new Runnable() { @Override public void run() { getActivityTab().goBack(); } }, MS_BEFORE_NAVIGATING_BACK_FROM_INTERSTITIAL); } @Override public void onDidDetachInterstitialPage(Tab tab) { updateUrlBar(); } @Override public void didFirstVisuallyNonEmptyPaint(Tab tab) { hideSplashScreen(WebappUma.SPLASHSCREEN_HIDES_REASON_PAINT); } @Override public void onPageLoadFinished(Tab tab) { hideSplashScreen(WebappUma.SPLASHSCREEN_HIDES_REASON_LOAD_FINISHED); } @Override public void onPageLoadFailed(Tab tab, int errorCode) { hideSplashScreen(WebappUma.SPLASHSCREEN_HIDES_REASON_LOAD_FAILED); } @Override public void onCrash(Tab tab, boolean sadTabShown) { hideSplashScreen(WebappUma.SPLASHSCREEN_HIDES_REASON_CRASH); } }; } private void updateTaskDescription() { String title = null; if (!TextUtils.isEmpty(mWebappInfo.shortName())) { title = mWebappInfo.shortName(); } else if (getActivityTab() != null) { title = getActivityTab().getTitle(); } Bitmap icon = null; if (mWebappInfo.icon() != null) { icon = mWebappInfo.icon(); } else if (getActivityTab() != null) { icon = mLargestFavicon; } if (mBrandColor == null && mWebappInfo.hasValidThemeColor()) { mBrandColor = (int) mWebappInfo.themeColor(); } int taskDescriptionColor = ApiCompatibilityUtils.getColor(getResources(), R.color.default_primary_color); int statusBarColor = Color.BLACK; if (mBrandColor != null) { taskDescriptionColor = mBrandColor; statusBarColor = ColorUtils.getDarkenedColorForStatusBar(mBrandColor); } ApiCompatibilityUtils.setTaskDescription(this, title, icon, ColorUtils.getOpaqueColor(taskDescriptionColor)); ApiCompatibilityUtils.setStatusBarColor(getWindow(), statusBarColor); } @Override protected void setStatusBarColor(Tab tab, int color) { // Intentionally do nothing as WebappActivity explicitly sets status bar color. } /** * Returns a unique identifier for this WebappActivity. * Note: do not call this function when you need {@link WebappInfo#id()}. Subclasses like * WebappManagedActivity and WebApkManagedActivity overwrite this function and return the * index of the activity. */ protected String getActivityId() { return mWebappInfo.id(); } /** * Get the active directory by this web app. * * @return The directory used for the current web app. */ @Override protected final File getActivityDirectory() { return mDirectoryManager.getWebappDirectory(this, getActivityId()); } private void hideSplashScreen(final int reason) { if (mSplashScreen == null) return; mSplashScreen.animate() .alpha(0f) .withEndAction(new Runnable() { @Override public void run() { ViewGroup contentView = (ViewGroup) findViewById(android.R.id.content); if (mSplashScreen == null) return; contentView.removeView(mSplashScreen); mSplashScreen = null; mWebappUma.splashscreenHidden(reason); } }); } @VisibleForTesting boolean isSplashScreenVisibleForTests() { return mSplashScreen != null; } @VisibleForTesting ViewGroup getSplashScreenForTests() { return mSplashScreen; } @VisibleForTesting WebappUrlBar getUrlBarForTests() { return mUrlBar; } @VisibleForTesting boolean isUrlBarVisible() { return findViewById(R.id.control_container).getVisibility() == View.VISIBLE; } @Override protected final ChromeFullscreenManager createFullscreenManager( ControlContainer controlContainer) { return new ChromeFullscreenManager(this, controlContainer, getTabModelSelector(), getControlContainerHeightResource(), false /* supportsBrowserOverride */); } @Override public int getControlContainerHeightResource() { return R.dimen.webapp_control_container_height; } @Override protected Drawable getBackgroundDrawable() { return null; } @Override protected TabDelegateFactory createTabDelegateFactory() { return new WebappDelegateFactory(this); } // We're temporarily disable CS on webapp since there are some issues. (http://crbug.com/471950) // TODO(changwan): re-enable it once the issues are resolved. @Override protected boolean isContextualSearchAllowed() { return false; } }