blob: 74a979772340b0ad2825808bb43476572f53d4b5 [file] [log] [blame]
// Copyright 2020 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.browserservices;
import android.content.Context;
import android.content.pm.PackageManager;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;
import androidx.browser.customtabs.CustomTabsSessionToken;
import org.chromium.base.ContextUtils;
import org.chromium.base.Promise;
import org.chromium.base.annotations.NativeMethods;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.app.ChromeActivity;
import org.chromium.chrome.browser.browserservices.ui.controller.Verifier;
import org.chromium.chrome.browser.browserservices.ui.controller.trustedwebactivity.ClientPackageNameProvider;
import org.chromium.chrome.browser.customtabs.CustomTabsConnection;
import org.chromium.chrome.browser.customtabs.content.TabObserverRegistrar;
import org.chromium.chrome.browser.customtabs.content.TabObserverRegistrar.CustomTabTabObserver;
import org.chromium.chrome.browser.dependency_injection.ActivityScope;
import org.chromium.chrome.browser.flags.ChromeFeatureList;
import org.chromium.chrome.browser.lifecycle.ActivityLifecycleDispatcher;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.content_public.browser.NavigationHandle;
import org.chromium.content_public.browser.RenderFrameHost;
import org.chromium.net.NetError;
import org.chromium.ui.widget.Toast;
import javax.inject.Inject;
/**
* This class enforces a quality bar on the websites shown inside Trusted Web Activities. For
* example triggering if a link from a page on the verified origin 404s.
*
* The current plan for when QualityEnforcer is triggered is to finish Chrome and send a signal
* back to the TWA shell, causing it to crash. The purpose of this is to bring TWAs in line with
* native applications - if a native application tries to start an Activity that doesn't exist, it
* will crash. We should hold web apps to the same standard.
*/
@ActivityScope
public class QualityEnforcer {
@VisibleForTesting
static final String CRASH = "quality_enforcement.crash";
@VisibleForTesting
static final String KEY_CRASH_REASON = "crash_reason";
@VisibleForTesting
static final String KEY_SUCCESS = "success";
private final ChromeActivity<?> mActivity;
private final Verifier mVerifier;
private final CustomTabsConnection mConnection;
private final CustomTabsSessionToken mSessionToken;
private final ClientPackageNameProvider mClientPackageNameProvider;
private final BrowserServicesIntentDataProvider mIntentDataProvider;
private final TrustedWebActivityUmaRecorder mUmaRecorder;
private boolean mFirstNavigationFinished;
private boolean mOriginVerified;
private final CustomTabTabObserver mTabObserver = new CustomTabTabObserver() {
@Override
public void onDidFinishNavigation(Tab tab, NavigationHandle navigation) {
if (!navigation.hasCommitted() || !navigation.isInMainFrame()
|| navigation.isSameDocument()) {
return;
}
if (!mFirstNavigationFinished) {
String loadUrl = mIntentDataProvider.getUrlToLoad();
mFirstNavigationFinished = true;
mVerifier.verify(loadUrl).then((verified) -> {
if (!verified) {
trigger(tab, QualityEnforcementViolationType.DIGITAL_ASSET_LINK,
mIntentDataProvider.getUrlToLoad(), 0);
}
});
}
String newUrl = tab.getOriginalUrl();
if (isNavigationInScope(newUrl)) {
if (navigation.httpStatusCode() == 404) {
trigger(tab, QualityEnforcementViolationType.HTTP_ERROR404, newUrl,
navigation.httpStatusCode());
} else if (navigation.httpStatusCode() >= 500
&& navigation.httpStatusCode() <= 599) {
trigger(tab, QualityEnforcementViolationType.HTTP_ERROR5XX, newUrl,
navigation.httpStatusCode());
} else if (navigation.errorCode() == NetError.ERR_INTERNET_DISCONNECTED) {
trigger(tab, QualityEnforcementViolationType.UNAVAILABLE_OFFLINE, newUrl,
navigation.httpStatusCode());
}
}
}
@Override
public void onObservingDifferentTab(@NonNull Tab tab) {
// On tab switches, update the stored verification state.
isNavigationInScope(tab.getOriginalUrl());
}
};
@Inject
public QualityEnforcer(ChromeActivity<?> activity,
ActivityLifecycleDispatcher lifecycleDispatcher,
TabObserverRegistrar tabObserverRegistrar,
BrowserServicesIntentDataProvider intentDataProvider, CustomTabsConnection connection,
Verifier verifier, ClientPackageNameProvider clientPackageNameProvider,
TrustedWebActivityUmaRecorder umaRecorder) {
mActivity = activity;
mVerifier = verifier;
mSessionToken = intentDataProvider.getSession();
mIntentDataProvider = intentDataProvider;
mConnection = connection;
mClientPackageNameProvider = clientPackageNameProvider;
mUmaRecorder = umaRecorder;
// Initialize the value to true before the first navigation.
mOriginVerified = true;
tabObserverRegistrar.registerActivityTabObserver(mTabObserver);
}
private void trigger(
Tab tab, @QualityEnforcementViolationType int type, String url, int httpStatusCode) {
mUmaRecorder.recordQualityEnforcementViolation(tab, type);
if (ChromeFeatureList.isEnabled(
ChromeFeatureList.TRUSTED_WEB_ACTIVITY_QUALITY_ENFORCEMENT_WARNING)) {
showErrorToast(getToastMessage(type, url, httpStatusCode));
if (tab.getWebContents() != null) {
QualityEnforcerJni.get().reportDevtoolsIssue(
tab.getWebContents().getMainFrame(), type, url, httpStatusCode);
}
}
if (!ChromeFeatureList.isEnabled(
ChromeFeatureList.TRUSTED_WEB_ACTIVITY_QUALITY_ENFORCEMENT)) {
return;
}
// Notify the client app.
Bundle args = new Bundle();
args.putString(KEY_CRASH_REASON, toTwaCrashMessage(type, url, httpStatusCode));
Bundle result = mConnection.sendExtraCallbackWithResult(mSessionToken, CRASH, args);
boolean success = result != null && result.getBoolean(KEY_SUCCESS);
// Do not crash on assetlink failures if the client app does not have installer package
// name.
if (type == QualityEnforcementViolationType.DIGITAL_ASSET_LINK && !isDebugInstall()) {
return;
}
if (ChromeFeatureList.isEnabled(
ChromeFeatureList.TRUSTED_WEB_ACTIVITY_QUALITY_ENFORCEMENT_FORCED)
|| success) {
mUmaRecorder.recordQualityEnforcementViolationCrashed(type);
mActivity.finish();
}
}
private void showErrorToast(String message) {
Context context = ContextUtils.getApplicationContext();
PackageManager pm = context.getPackageManager();
// Only shows the toast when the TWA client app does not have installer info, i.e. install
// via adb instead of a store.
if (!isDebugInstall()) {
Toast.makeText(context, message, Toast.LENGTH_LONG).show();
}
}
/*
* Updates whether the current url is verified and returns whether the source and destination
* are both on the verified origin.
*/
private boolean isNavigationInScope(String newUrl) {
if (newUrl.equals("")) return false;
boolean wasVerified = mOriginVerified;
Promise<Boolean> result = mVerifier.verify(newUrl);
mOriginVerified = !result.isFulfilled() || result.getResult();
return wasVerified && mOriginVerified;
}
/* Get the localized string for toast message. */
private String getToastMessage(
@QualityEnforcementViolationType int type, String url, int httpStatusCode) {
switch (type) {
case QualityEnforcementViolationType.HTTP_ERROR404:
case QualityEnforcementViolationType.HTTP_ERROR5XX:
return ContextUtils.getApplicationContext().getString(
R.string.twa_quality_enforcement_violation_error, httpStatusCode, url);
case QualityEnforcementViolationType.UNAVAILABLE_OFFLINE:
return ContextUtils.getApplicationContext().getString(
R.string.twa_quality_enforcement_violation_offline, url);
case QualityEnforcementViolationType.DIGITAL_ASSET_LINK:
return ContextUtils.getApplicationContext().getString(
R.string.twa_quality_enforcement_violation_asset_link, url);
default:
return "";
}
}
/*
* Get the string for sending message to TWA client app. We are not using the localized one as
* the toast because this is used in TWA's crash message.
*/
private String toTwaCrashMessage(
@QualityEnforcementViolationType int type, String url, int httpStatusCode) {
switch (type) {
case QualityEnforcementViolationType.HTTP_ERROR404:
case QualityEnforcementViolationType.HTTP_ERROR5XX:
return httpStatusCode + " on " + url;
case QualityEnforcementViolationType.UNAVAILABLE_OFFLINE:
return "Page unavailable offline: " + url;
case QualityEnforcementViolationType.DIGITAL_ASSET_LINK:
return "Digital asset links verification failed on " + url;
default:
return "";
}
}
private boolean isDebugInstall() {
// TODO(crbug.com/1136153) Need to figure out why the client package name can be null.
if (mClientPackageNameProvider.get() == null) return false;
return ContextUtils.getApplicationContext().getPackageManager().getInstallerPackageName(
mClientPackageNameProvider.get())
!= null;
}
@NativeMethods
interface Natives {
void reportDevtoolsIssue(
RenderFrameHost renderFrameHost, int type, String url, int httpStatusCode);
}
}