Commit 376db7a9 authored by Pavel Shmakov's avatar Pavel Shmakov Committed by Commit Bot

🤝 Implement persistent notification showing while a Trusted Web Activity is in foreground

The notification offers to manage site data and to share info about the webpage.

The notification is shown while the activity is started. It is not removed when the user leaves
the origin associated with the app by following links.

Bug: 888953
Change-Id: I297e4090b03c081b901a1339f25485ab63640104
Reviewed-on: https://chromium-review.googlesource.com/c/1307400
Commit-Queue: Pavel Shmakov <pshmakov@chromium.org>
Reviewed-by: default avatarPeter Conn <peconn@chromium.org>
Reviewed-by: default avatarYusuf Ozuysal <yusufo@chromium.org>
Cr-Commit-Position: refs/heads/master@{#605800}
parent a3310dae
......@@ -367,7 +367,7 @@ public class LaunchIntentDispatcher implements IntentHandler.IntentHandlerDelega
* in the same task.
*/
private void launchCustomTabActivity() {
boolean handled = BrowserSessionContentUtils.handleInActiveContentIfNeeded(mIntent);
boolean handled = BrowserSessionContentUtils.handleBrowserServicesIntent(mIntent);
if (handled) return;
maybePrefetchDnsInBackground();
......
......@@ -62,4 +62,9 @@ public interface BrowserSessionContentHandler {
* @return The url of a pending navigation, if any.
*/
@Nullable String getPendingUrl();
/**
* Triggers sharing of currently shown webpage similarly to the "Share" menu action.
*/
void triggerSharingFlow();
}
......@@ -5,10 +5,12 @@
package org.chromium.chrome.browser.browserservices;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
import android.net.Uri;
import android.os.IBinder;
import android.support.annotation.Nullable;
import android.support.customtabs.CustomTabsService;
import android.support.customtabs.CustomTabsSessionToken;
import android.text.TextUtils;
......@@ -20,6 +22,7 @@ import org.chromium.chrome.browser.ChromeActivity;
import org.chromium.chrome.browser.IntentHandler;
import org.chromium.chrome.browser.UrlConstants;
import org.chromium.chrome.browser.customtabs.CustomTabsConnection;
import org.chromium.chrome.browser.document.ChromeLauncherActivity;
import org.chromium.content_public.browser.LoadUrlParams;
/**
......@@ -29,8 +32,15 @@ import org.chromium.content_public.browser.LoadUrlParams;
*/
public class BrowserSessionContentUtils {
private static final String TAG = "BrowserSession_Utils";
@Nullable
private static BrowserSessionContentHandler sActiveContentHandler;
/** Extra that is passed to intent to trigger a certain action within a running activity. */
private static final String EXTRA_INTERNAL_ACTION =
"org.chromium.chrome.extra.EXTRA_INTERNAL_ACTION";
private static final String INTERNAL_ACTION_SHARE =
"org.chromium.chrome.action.INTERNAL_ACTION_SHARE";
/**
* Sets the currently active {@link BrowserSessionContentHandler} in focus.
* @param contentHandler {@link BrowserSessionContentHandler} to set.
......@@ -47,15 +57,37 @@ public class BrowserSessionContentUtils {
*
* @return Whether the active {@link BrowserSessionContentHandler} has handled the intent.
*/
public static boolean handleInActiveContentIfNeeded(Intent intent) {
CustomTabsSessionToken session = CustomTabsSessionToken.getSessionTokenFromIntent(intent);
public static boolean handleBrowserServicesIntent(Intent intent) {
String url = IntentHandler.getUrlFromIntent(intent);
if (TextUtils.isEmpty(url)) return false;
CustomTabsSessionToken session = CustomTabsSessionToken.getSessionTokenFromIntent(intent);
if (handleInternalIntent(intent, session)) return true;
// Must be called regardless of whether or not an external intent can be handled in active
// content.
CustomTabsConnection.getInstance().onHandledIntent(session, url, intent);
if (sActiveContentHandler == null) return false;
if (session == null || !session.equals(sActiveContentHandler.getSession())) return false;
return handleExternalIntent(intent, url, session);
}
private static boolean handleInternalIntent(Intent intent,
@Nullable CustomTabsSessionToken session) {
if (!IntentHandler.wasIntentSenderChrome(intent)) return false;
if (!sessionMatchesActiveContent(session)) return false;
String internalAction = intent.getStringExtra(EXTRA_INTERNAL_ACTION);
if (INTERNAL_ACTION_SHARE.equals(internalAction)) {
sActiveContentHandler.triggerSharingFlow();
return true;
}
return false;
}
private static boolean handleExternalIntent(Intent intent, String url,
@Nullable CustomTabsSessionToken session) {
if (!sessionMatchesActiveContent(session)) return false;
if (sActiveContentHandler.shouldIgnoreIntent(intent)) {
Log.w(TAG, "Incoming intent to Custom Tab was ignored.");
return false;
......@@ -65,6 +97,11 @@ public class BrowserSessionContentUtils {
return true;
}
private static boolean sessionMatchesActiveContent(@Nullable CustomTabsSessionToken session) {
return session != null && sActiveContentHandler != null &&
session.equals(sActiveContentHandler.getSession());
}
/**
* @return Whether the given session is the currently active session.
*/
......@@ -151,4 +188,17 @@ public class BrowserSessionContentUtils {
}
return sActiveContentHandler.updateRemoteViews(remoteViews, clickableIDs, pendingIntent);
}
/**
* Creates a share intent to be triggered in currently running activity.
* @param originalIntent - intent with which the activity was launched.
*/
public static Intent createShareIntent(Context context, Intent originalIntent) {
Intent intent = new Intent(originalIntent)
.putExtra(EXTRA_INTERNAL_ACTION, INTERNAL_ACTION_SHARE)
// Make the new intent follow the same route as the original one
.setClass(context, ChromeLauncherActivity.class);
IntentHandler.addTrustedIntentExtras(intent);
return intent;
}
}
// Copyright 2017 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 static android.app.PendingIntent.FLAG_UPDATE_CURRENT;
import static org.chromium.chrome.browser.dependency_injection.ChromeCommonQualifiers.APP_CONTEXT;
import static org.chromium.chrome.browser.notifications.NotificationConstants.NOTIFICATION_ID_TWA_PERSISTENT;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.os.Build;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.Looper;
import android.support.annotation.Nullable;
import android.support.v4.app.NotificationCompat;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.customtabs.CustomTabIntentDataProvider;
import org.chromium.chrome.browser.dependency_injection.ActivityScope;
import org.chromium.chrome.browser.init.ActivityLifecycleDispatcher;
import org.chromium.chrome.browser.lifecycle.Destroyable;
import org.chromium.chrome.browser.lifecycle.StartStopWithNativeObserver;
import org.chromium.chrome.browser.notifications.NotificationBuilderFactory;
import org.chromium.chrome.browser.notifications.channels.ChannelDefinitions;
import org.chromium.chrome.browser.preferences.Preferences;
import org.chromium.chrome.browser.preferences.PreferencesLauncher;
import org.chromium.chrome.browser.preferences.website.SingleWebsitePreferences;
import javax.inject.Inject;
import javax.inject.Named;
/**
* Publishes and dismisses the notification when running Trusted Web Activities. The notification
* offers to manage site data and to share info about it.
*
* The notification is shown while the activity is in started state. It is not removed when the user
* leaves the origin associated with the app by following links.
*/
@ActivityScope
public class PersistentNotificationController implements StartStopWithNativeObserver, Destroyable {
private final Context mAppContext;
private final CustomTabIntentDataProvider mIntentDataProvider;
@Nullable
private String mVerifiedPackage;
@Nullable
private Origin mVerifiedOrigin;
private boolean mStarted;
@Nullable
private Handler mHandler;
@Inject
public PersistentNotificationController(@Named(APP_CONTEXT) Context context,
ActivityLifecycleDispatcher lifecycleDispatcher,
CustomTabIntentDataProvider intentDataProvider) {
mAppContext = context;
mIntentDataProvider = intentDataProvider;
lifecycleDispatcher.register(this);
}
@Override
public void onStartWithNative() {
mStarted = true;
if (mVerifiedPackage != null) {
publish();
}
}
@Override
public void onStopWithNative() {
mStarted = false;
dismiss();
}
@Override
public void destroy() {
killBackgroundThread();
}
/**
* Called when the relationship between an origin and an app with given package name has been
* verified.
*/
public void onOriginVerifiedForPackage(Origin origin, String packageName) {
if (packageName.equals(mVerifiedPackage)) {
return;
}
mVerifiedPackage = packageName;
mVerifiedOrigin = origin;
if (mStarted) {
publish();
}
}
private void publish() {
postToBackgroundThread(new PublishTask(
mVerifiedPackage, mVerifiedOrigin, mAppContext, mIntentDataProvider.getIntent()));
}
private void dismiss() {
postToBackgroundThread(new DismissTask(mAppContext, mVerifiedPackage));
}
private void postToBackgroundThread(Runnable task) {
if (mHandler == null) {
HandlerThread backgroundThread = new HandlerThread("TwaPersistentNotification");
backgroundThread.start();
mHandler = new Handler(backgroundThread.getLooper());
}
mHandler.post(task);
}
private void killBackgroundThread() {
if (mHandler == null) {
return;
}
Looper looper = mHandler.getLooper();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) {
looper.quitSafely();
} else {
looper.quit();
}
}
private static class PublishTask implements Runnable {
private final String mPackageName;
private final Origin mOrigin;
private final Context mAppContext;
private final Intent mCustomTabsIntent;
private PublishTask(String packageName, Origin origin, Context appContext,
Intent customTabsIntent) {
mPackageName = packageName;
mOrigin = origin;
mAppContext = appContext;
mCustomTabsIntent = customTabsIntent;
}
@Override
public void run() {
Notification notification = createNotification();
NotificationManager nm = (NotificationManager) mAppContext.getSystemService(
Context.NOTIFICATION_SERVICE);
if (nm != null) {
nm.notify(mPackageName, NOTIFICATION_ID_TWA_PERSISTENT, notification);
}
}
private Notification createNotification() {
return NotificationBuilderFactory
.createChromeNotificationBuilder(true /* preferCompat */,
ChannelDefinitions.ChannelId.BROWSER)
.setSmallIcon(R.drawable.ic_chrome)
.setContentTitle(makeTitle())
.setContentText(
mAppContext.getString(R.string.app_running_in_chrome_disclosure))
.setAutoCancel(false)
.setOngoing(true)
.setPriorityBeforeO(NotificationCompat.PRIORITY_LOW)
.addAction(0 /* icon */, // TODO(pshmakov): set the icons.
mAppContext.getString(R.string.share),
makeShareIntent())
.addAction(0 /* icon */,
mAppContext.getString(R.string.twa_manage_data),
makeManageDataIntent())
.build();
}
private String makeTitle() {
PackageManager packageManager = mAppContext.getPackageManager();
try {
return packageManager
.getApplicationLabel(packageManager.getApplicationInfo(mPackageName, 0))
.toString();
} catch (PackageManager.NameNotFoundException e) {
assert false : mPackageName + " not found";
return "";
}
}
private PendingIntent makeManageDataIntent() {
Intent settingsIntent = PreferencesLauncher.createIntentForSettingsPage(
mAppContext, SingleWebsitePreferences.class.getName());
settingsIntent.putExtra(Preferences.EXTRA_SHOW_FRAGMENT_ARGUMENTS,
SingleWebsitePreferences.createFragmentArgsForSite(mOrigin.toString()));
return PendingIntent.getActivity(mAppContext, 0, settingsIntent, FLAG_UPDATE_CURRENT);
}
private PendingIntent makeShareIntent() {
Intent shareIntent =
BrowserSessionContentUtils.createShareIntent(mAppContext, mCustomTabsIntent);
return PendingIntent.getActivity(mAppContext, 0, shareIntent, FLAG_UPDATE_CURRENT);
}
}
private static class DismissTask implements Runnable {
private final Context mAppContext;
private final String mPackageName;
private DismissTask(Context appContext, String packageName) {
mAppContext = appContext;
mPackageName = packageName;
}
@Override
public void run() {
NotificationManager nm = (NotificationManager) mAppContext.getSystemService(
Context.NOTIFICATION_SERVICE);
if (nm != null) {
nm.cancel(mPackageName, NOTIFICATION_ID_TWA_PERSISTENT);
}
}
}
}
......@@ -47,6 +47,7 @@ public class TrustedWebActivityUi
private final CustomTabIntentDataProvider mIntentDataProvider;
private final ActivityTabProvider mActivityTabProvider;
private final CustomTabBrowserControlsVisibilityDelegate mControlsVisibilityDelegate;
private final PersistentNotificationController mNotificationController;
private boolean mInTrustedWebActivity = true;
......@@ -69,8 +70,7 @@ public class TrustedWebActivityUi
Origin origin = new Origin(url);
boolean verified =
OriginVerifier.isValidOrigin(packageName, origin, RELATIONSHIP);
if (verified) registerClientAppData(packageName, origin);
setTrustedWebActivityMode(verified);
handleVerificationResult(verified, packageName, origin);
}
};
......@@ -81,7 +81,8 @@ public class TrustedWebActivityUi
CustomTabsConnection customTabsConnection,
ActivityLifecycleDispatcher lifecycleDispatcher,
TabObserverRegistrar tabObserverRegistrar, ActivityTabProvider activityTabProvider,
CustomTabBrowserControlsVisibilityDelegate controlsVisibilityDelegate) {
CustomTabBrowserControlsVisibilityDelegate controlsVisibilityDelegate,
PersistentNotificationController notificationController) {
mFullscreenManager = fullscreenManager;
mClientAppDataRecorder = clientAppDataRecorder;
mDisclosure = disclosure;
......@@ -89,6 +90,7 @@ public class TrustedWebActivityUi
mIntentDataProvider = intentDataProvider;
mActivityTabProvider = activityTabProvider;
mControlsVisibilityDelegate = controlsVisibilityDelegate;
mNotificationController = notificationController;
tabObserverRegistrar.registerTabObserver(mVerifyOnPageLoadObserver);
lifecycleDispatcher.register(this);
}
......@@ -127,11 +129,18 @@ public class TrustedWebActivityUi
if (!origin.equals(new Origin(tab.getUrl()))) return;
BrowserServicesMetrics.recordTwaOpened();
if (verified) registerClientAppData(packageName, origin);
setTrustedWebActivityMode(verified);
handleVerificationResult(verified, packageName, origin);
}, packageName, RELATIONSHIP).start(origin);
}
private void handleVerificationResult(boolean verified, String packageName, Origin origin) {
if (verified) {
registerClientAppData(packageName, origin);
mNotificationController.onOriginVerifiedForPackage(origin, packageName);
}
setTrustedWebActivityMode(verified);
}
@Override
public void onPreInflationStartup() {}
......
......@@ -84,6 +84,7 @@ import org.chromium.chrome.browser.infobar.InfoBarContainer;
import org.chromium.chrome.browser.net.spdyproxy.DataReductionProxySettings;
import org.chromium.chrome.browser.page_info.PageInfoController;
import org.chromium.chrome.browser.rappor.RapporServiceBridge;
import org.chromium.chrome.browser.share.ShareMenuActionHandler;
import org.chromium.chrome.browser.tab.EmptyTabObserver;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.tab.TabDelegateFactory;
......@@ -612,6 +613,12 @@ public class CustomTabActivity extends ChromeActivity<CustomTabActivityComponent
.getPendingEntry();
return entry != null ? entry.getUrl() : null;
}
@Override
public void triggerSharingFlow() {
ShareMenuActionHandler.getInstance().onShareMenuItemSelected(CustomTabActivity.this,
getActivityTab(), false /* shareDirectly */, false /* isIncognito */);
}
};
maybeLoadModule();
......
......@@ -786,4 +786,11 @@ public class CustomTabIntentDataProvider extends BrowserSessionDataProvider {
Pattern getExtraModuleManagedUrlsPattern() {
return mModuleManagedUrlsPattern;
}
/**
* @return the Intent this instance was created with.
*/
public Intent getIntent() {
return mIntent;
}
}
......@@ -68,6 +68,12 @@ public class NotificationConstants {
*/
public static final int NOTIFICATION_ID_WEBAPP_ACTIONS = 5;
/**
* Unique identifier for the persistent notification displayed while a Trusted Web Activity is
* in foreground.
*/
public static final int NOTIFICATION_ID_TWA_PERSISTENT = 6;
/**
* Unique identifier for the summary notification for downloads. Using the ID this summary was
* going to have before it was migrated here.
......
......@@ -3596,6 +3596,9 @@ However, you aren’t invisible. Going private doesn’t hide your browsing from
<message name="IDS_CLEAR_RELATED_DATA" desc="Notification text asking if the user wants to clear data for the given url as they have just uninstalled/cleared an app linked to that URL.">
Would you like to clear data for <ph name="URL">%1$s<ex>youtube.com</ex></ph>?
</message>
<message name="IDS_TWA_MANAGE_DATA" desc="Text on button of the notification that would direct the user to site settings for the site being run in a Trusted Web Activity" translateable="false">
Manage data
</message>
<message name="IDS_WEBAPP_TAP_TO_COPY_URL" desc="Message on the notification that indicates that taping it will copy a Web App's URL into the clipboard.">
Tap to copy the URL for this app
......
......@@ -173,6 +173,7 @@ chrome_java_sources = [
"java/src/org/chromium/chrome/browser/browserservices/DomainDataCleaner.java",
"java/src/org/chromium/chrome/browser/browserservices/Origin.java",
"java/src/org/chromium/chrome/browser/browserservices/OriginVerifier.java",
"java/src/org/chromium/chrome/browser/browserservices/PersistentNotificationController.java",
"java/src/org/chromium/chrome/browser/browserservices/PostMessageHandler.java",
"java/src/org/chromium/chrome/browser/browserservices/Relationship.java",
"java/src/org/chromium/chrome/browser/browserservices/TrustedWebActivityClient.java",
......
......@@ -47,7 +47,7 @@ public class TrustedWebActivityTest {
public CustomTabActivityTestRule mCustomTabActivityTestRule = new CustomTabActivityTestRule();
private static final String TEST_PAGE = "/chrome/test/data/android/google.html";
private static final String PACKAGE_NAME = "package.name";
private static final String PACKAGE_NAME = "org.chromium.chrome"; // Package name of test apk.
private EmbeddedTestServer mTestServer;
private String mTestPage;
......
......@@ -1206,7 +1206,7 @@ public class CustomTabActivityTest {
Assert.assertEquals(getActivity().getIntentDataProvider().getSession(), session);
Assert.assertFalse("CustomTabContentHandler handled intent with wrong session",
ThreadUtils.runOnUiThreadBlockingNoException(() -> {
return BrowserSessionContentUtils.handleInActiveContentIfNeeded(
return BrowserSessionContentUtils.handleBrowserServicesIntent(
CustomTabsTestUtils.createMinimalCustomTabIntent(context, mTestPage2));
}));
CriteriaHelper.pollInstrumentationThread(
......@@ -1214,7 +1214,7 @@ public class CustomTabActivityTest {
Assert.assertTrue("CustomTabContentHandler can't handle intent with same session",
ThreadUtils.runOnUiThreadBlockingNoException(() -> {
intent.setData(Uri.parse(mTestPage2));
return BrowserSessionContentUtils.handleInActiveContentIfNeeded(intent);
return BrowserSessionContentUtils.handleBrowserServicesIntent(intent);
}));
final Tab tab = getActivity().getActivityTab();
final CallbackHelper pageLoadFinishedHelper = new CallbackHelper();
......@@ -1288,7 +1288,7 @@ public class CustomTabActivityTest {
});
Assert.assertTrue("CustomTabContentHandler can't handle intent with same session",
ThreadUtils.runOnUiThreadBlockingNoException(
() -> BrowserSessionContentUtils.handleInActiveContentIfNeeded(intent)));
() -> BrowserSessionContentUtils.handleBrowserServicesIntent(intent)));
pageLoadFinishedHelper.waitForCallback(0);
}
......@@ -1328,7 +1328,7 @@ public class CustomTabActivityTest {
});
Assert.assertTrue("CustomTabContentHandler can't handle intent with same session",
ThreadUtils.runOnUiThreadBlockingNoException(
() -> BrowserSessionContentUtils.handleInActiveContentIfNeeded(intent)));
() -> BrowserSessionContentUtils.handleBrowserServicesIntent(intent)));
pageLoadFinishedHelper.waitForCallback(0);
}
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment