blob: c1d9c1533da57816fc43b1e8f0833eb96036da17 [file] [log] [blame]
// 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.app.Activity;
import android.content.Intent;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.StrictMode;
import android.os.SystemClock;
import android.text.TextUtils;
import android.util.Base64;
import org.chromium.base.ApiCompatibilityUtils;
import org.chromium.base.ApplicationStatus;
import org.chromium.base.ContextUtils;
import org.chromium.base.Log;
import org.chromium.chrome.browser.IntentHandler;
import org.chromium.chrome.browser.ShortcutHelper;
import org.chromium.chrome.browser.ShortcutSource;
import org.chromium.chrome.browser.document.ChromeLauncherActivity;
import org.chromium.chrome.browser.metrics.LaunchMetrics;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.util.IntentUtils;
import org.chromium.webapk.lib.client.WebApkValidator;
import org.chromium.webapk.lib.common.WebApkConstants;
import java.lang.ref.WeakReference;
/**
* Launches web apps. This was separated from the ChromeLauncherActivity because the
* ChromeLauncherActivity is not allowed to be excluded from Android's Recents: crbug.com/517426.
*/
public class WebappLauncherActivity extends Activity {
/**
* Action fired when an Intent is trying to launch a WebappActivity.
* Never change the package name or the Intents will fail to launch.
*/
public static final String ACTION_START_WEBAPP =
"com.google.android.apps.chrome.webapps.WebappManager.ACTION_START_WEBAPP";
private static final String TAG = "webapps";
/** Timestamp of Activity creation for tracking how long it takes to complete. */
private long mCreateTime;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
mCreateTime = SystemClock.elapsedRealtime();
Intent launchIntent = createLaunchIntent();
if (launchIntent == null) {
ApiCompatibilityUtils.finishAndRemoveTask(this);
return;
}
IntentUtils.safeStartActivity(this, launchIntent);
if (IntentUtils.isIntentForNewTaskOrNewDocument(launchIntent)) {
ApiCompatibilityUtils.finishAndRemoveTask(this);
} else {
finish();
}
}
public Intent createLaunchIntent() {
Intent intent = getIntent();
ChromeWebApkHost.init();
WebappInfo webappInfo = tryCreateWebappInfo(intent);
// {@link WebApkInfo#create()} and {@link WebappInfo#create()} return null if the intent
// does not specify required values such as the uri.
if (webappInfo == null) {
String url = IntentUtils.safeGetStringExtra(intent, ShortcutHelper.EXTRA_URL);
return createLaunchInTabIntent(url, ShortcutSource.UNKNOWN);
}
String webappUrl = webappInfo.uri().toString();
int webappSource = webappInfo.source();
String webappMac = IntentUtils.safeGetStringExtra(intent, ShortcutHelper.EXTRA_MAC);
// Permit the launch to a standalone web app frame if any of the following are true:
// - the request was for a WebAPK that is valid;
// - the MAC is present and valid for the homescreen shortcut to be opened;
// - the intent was sent by Chrome.
if (webappInfo.isForWebApk() || isValidMacForUrl(webappUrl, webappMac)
|| wasIntentFromChrome(intent)) {
int source = webappSource;
// Retrieves the source of the WebAPK from WebappDataStorage if it is unknown. The
// {@link webappSource} will not be unknown in the case of an external intent or a
// notification that launches a WebAPK. Otherwise, it's not trustworthy and we must read
// the SharedPreference to get the installation source.
if (webappInfo.isForWebApk() && (webappSource == ShortcutSource.UNKNOWN)) {
source = getWebApkSource(webappInfo);
}
LaunchMetrics.recordHomeScreenLaunchIntoStandaloneActivity(
webappUrl, source, webappInfo.displayMode());
// Add all information needed to launch WebappActivity without {@link
// WebappActivity#sWebappInfoMap} to launch intent. When the Android OS has killed a
// WebappActivity and the user selects the WebappActivity from "Android Recents" the
// WebappActivity is launched without going through WebappLauncherActivity first.
WebappActivity.addWebappInfo(webappInfo.id(), webappInfo);
Intent launchIntent = createWebappLaunchIntent(webappInfo);
IntentHandler.addTimestampToIntent(launchIntent, mCreateTime);
// Pass through WebAPK shell launch timestamp to the new intent.
long shellLaunchTimestamp =
IntentHandler.getWebApkShellLaunchTimestampFromIntent(intent);
IntentHandler.addShellLaunchTimestampToIntent(launchIntent, shellLaunchTimestamp);
return launchIntent;
}
Log.e(TAG, "Shortcut (%s) opened in Chrome.", webappUrl);
// The shortcut data doesn't match the current encoding. Change the intent action to
// launch the URL with a VIEW Intent in the regular browser.
return createLaunchInTabIntent(webappUrl, webappSource);
}
// Gets the source of a WebAPK from the WebappDataStorage if the source has been stored before.
private int getWebApkSource(WebappInfo webappInfo) {
WebappDataStorage storage = null;
StrictMode.ThreadPolicy oldPolicy = StrictMode.allowThreadDiskReads();
try {
WebappRegistry.warmUpSharedPrefsForId(webappInfo.id());
storage = WebappRegistry.getInstance().getWebappDataStorage(webappInfo.id());
} finally {
StrictMode.setThreadPolicy(oldPolicy);
}
if (storage != null) {
int source = storage.getSource();
if (source != ShortcutSource.UNKNOWN) {
return source;
}
}
return ShortcutSource.WEBAPK_UNKNOWN;
}
private Intent createLaunchInTabIntent(String webappUrl, int webappSource) {
if (TextUtils.isEmpty(webappUrl)) return null;
Intent launchIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(webappUrl));
launchIntent.setClassName(getPackageName(), ChromeLauncherActivity.class.getName());
launchIntent.putExtra(ShortcutHelper.REUSE_URL_MATCHING_TAB_ELSE_NEW_TAB, true);
launchIntent.putExtra(ShortcutHelper.EXTRA_SOURCE, webappSource);
launchIntent.setFlags(
Intent.FLAG_ACTIVITY_NEW_TASK | ApiCompatibilityUtils.getActivityNewDocumentFlag());
return launchIntent;
}
/**
* Checks whether or not the MAC is present and valid for the web app shortcut.
*
* The MAC is used to prevent malicious apps from launching Chrome into a full screen
* Activity for phishing attacks (among other reasons).
*
* @param url The URL for the web app.
* @param mac MAC to compare the URL against. See {@link WebappAuthenticator}.
* @return Whether the MAC is valid for the URL.
*/
private boolean isValidMacForUrl(String url, String mac) {
return mac != null
&& WebappAuthenticator.isUrlValid(this, url, Base64.decode(mac, Base64.DEFAULT));
}
private boolean wasIntentFromChrome(Intent intent) {
return IntentHandler.wasIntentSenderChrome(intent);
}
/**
* Creates an Intent to launch the web app.
* @param info Information about the web app.
*/
private static Intent createWebappLaunchIntent(WebappInfo info) {
String activityName = info.isForWebApk() ? WebApkActivity.class.getName()
: WebappActivity.class.getName();
boolean newTask = true;
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
// Specifically assign the app to a particular WebappActivity instance.
int namespace = info.isForWebApk()
? ActivityAssigner.ActivityAssignerNamespace.WEBAPK_NAMESPACE
: ActivityAssigner.ActivityAssignerNamespace.WEBAPP_NAMESPACE;
int activityIndex = ActivityAssigner.instance(namespace).assign(info.id());
activityName += String.valueOf(activityIndex);
// Finishes the old activity if it has been assigned to a different WebappActivity. See
// crbug.com/702998.
for (WeakReference<Activity> activityRef : ApplicationStatus.getRunningActivities()) {
Activity activity = activityRef.get();
if (!(activity instanceof WebappActivity)
|| !activity.getClass().getName().equals(activityName)) {
continue;
}
WebappActivity webappActivity = (WebappActivity) activity;
if (!TextUtils.equals(webappActivity.getWebappInfo().id(), info.id())) {
activity.finish();
}
break;
}
} else {
if (info.isForWebApk() && info.useTransparentSplash()) {
activityName = TransparentSplashWebApkActivity.class.getName();
newTask = false;
}
}
// Create an intent to launch the Webapp in an unmapped WebappActivity.
Intent launchIntent = new Intent();
launchIntent.setClassName(ContextUtils.getApplicationContext(), activityName);
info.setWebappIntentExtras(launchIntent);
// On L+, firing intents with the exact same data should relaunch a particular
// Activity.
launchIntent.setAction(Intent.ACTION_VIEW);
launchIntent.setData(Uri.parse(WebappActivity.WEBAPP_SCHEME + "://" + info.id()));
// Setting FLAG_ACTIVITY_CLEAR_TOP handles 2 edge cases:
// - If a legacy PWA is launching from a notification, we want to ensure that the URL being
// launched is the URL in the intent. If a paused WebappActivity exists for this id,
// then by default it will be focused and we have no way of sending the desired URL to
// it (the intent is swallowed). As a workaround, set the CLEAR_TOP flag to ensure that
// the existing Activity handles an update via onNewIntent().
// - If a WebAPK is having a CustomTabActivity on top of it in the same Task, and user
// clicks a link to takes them back to the scope of a WebAPK, we want to destroy the
// CustomTabActivity activity and go back to the WebAPK activity. It is intentional that
// Custom Tab will not be reachable with a back button.
// In addition FLAG_ACTIVITY_NEW_DOCUMENT is required otherwise on Samsung Lollipop devices
// an Intent to an existing top Activity (such as sent from the Webapp Actions Notification)
// will trigger a new WebappActivity to be launched and onCreate called instead of
// onNewIntent of the existing WebappActivity being called.
// TODO(pkotwicz): Route Webapp Actions Notification actions through new intent filter
// instead of WebappLauncherActivity. http://crbug.com/894610
if (newTask) {
launchIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK
| ApiCompatibilityUtils.getActivityNewDocumentFlag()
| Intent.FLAG_ACTIVITY_CLEAR_TOP);
} else {
launchIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
}
return launchIntent;
}
/**
* Brings a live WebappActivity back to the foreground if one exists for the given tab ID.
* @param tabId ID of the Tab to bring back to the foreground.
* @return True if a live WebappActivity was found, false otherwise.
*/
public static boolean bringWebappToFront(int tabId) {
if (tabId == Tab.INVALID_TAB_ID) return false;
for (WeakReference<Activity> activityRef : ApplicationStatus.getRunningActivities()) {
Activity activity = activityRef.get();
if (activity == null || !(activity instanceof WebappActivity)) continue;
WebappActivity webappActivity = (WebappActivity) activity;
if (webappActivity.getActivityTab() != null
&& webappActivity.getActivityTab().getId() == tabId) {
Tab tab = webappActivity.getActivityTab();
tab.getTabWebContentsDelegateAndroid().activateContents();
return true;
}
}
return false;
}
/** Tries to create WebappInfo/WebApkInfo for the intent. */
private WebappInfo tryCreateWebappInfo(Intent intent) {
// Builds WebApkInfo for the intent if the WebAPK package specified in the intent is a valid
// WebAPK and the URL specified in the intent can be fulfilled by the WebAPK.
String webApkPackage =
IntentUtils.safeGetStringExtra(intent, WebApkConstants.EXTRA_WEBAPK_PACKAGE_NAME);
String url = IntentUtils.safeGetStringExtra(intent, ShortcutHelper.EXTRA_URL);
if (!TextUtils.isEmpty(webApkPackage) && !TextUtils.isEmpty(url)
&& WebApkValidator.canWebApkHandleUrl(this, webApkPackage, url)) {
return WebApkInfo.create(intent);
}
Log.d(TAG, "%s is either not a WebAPK or %s is not within the WebAPK's scope",
webApkPackage, url);
return WebappInfo.create(intent);
}
}