// 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.customtabs; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.ServiceConnection; import android.content.pm.PackageManager; import android.os.IBinder; import android.os.SystemClock; import android.support.customtabs.CustomTabsCallback; import android.support.customtabs.CustomTabsSessionToken; import android.text.TextUtils; import android.util.SparseBooleanArray; import org.chromium.base.VisibleForTesting; import org.chromium.base.annotations.SuppressFBWarnings; import org.chromium.base.metrics.RecordHistogram; import org.chromium.chrome.browser.IntentHandler; import org.chromium.chrome.browser.util.UrlUtilities; import org.chromium.content_public.common.Referrer; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; /** Manages the clients' state for Custom Tabs. This class is threadsafe. */ @SuppressFBWarnings("CHROMIUM_SYNCHRONIZED_METHOD") class ClientManager { // Values for the "CustomTabs.PredictionStatus" UMA histogram. Append-only. @VisibleForTesting static final int NO_PREDICTION = 0; @VisibleForTesting static final int GOOD_PREDICTION = 1; @VisibleForTesting static final int BAD_PREDICTION = 2; private static final int PREDICTION_STATUS_COUNT = 3; // Values for the "CustomTabs.CalledWarmup" UMA histogram. Append-only. @VisibleForTesting static final int NO_SESSION_NO_WARMUP = 0; @VisibleForTesting static final int NO_SESSION_WARMUP = 1; @VisibleForTesting static final int SESSION_NO_WARMUP_ALREADY_CALLED = 2; @VisibleForTesting static final int SESSION_NO_WARMUP_NOT_CALLED = 3; @VisibleForTesting static final int SESSION_WARMUP = 4; @VisibleForTesting static final int SESSION_WARMUP_COUNT = 5; /** To be called when a client gets disconnected. */ public interface DisconnectCallback { public void run(CustomTabsSessionToken session); } /** Per-session values. */ private static class SessionParams { public final int uid; public final DisconnectCallback disconnectCallback; public final String packageName; public boolean mIgnoreFragments; private boolean mShouldHideDomain; private boolean mShouldPrerenderOnCellular; private boolean mShouldSendNavigationInfo; private ServiceConnection mKeepAliveConnection; private String mPredictedUrl; private long mLastMayLaunchUrlTimestamp; public SessionParams(Context context, int uid, DisconnectCallback callback) { this.uid = uid; packageName = getPackageName(context, uid); disconnectCallback = callback; } private static String getPackageName(Context context, int uid) { PackageManager packageManager = context.getPackageManager(); String[] packageList = packageManager.getPackagesForUid(uid); if (packageList.length != 1 || TextUtils.isEmpty(packageList[0])) return null; return packageList[0]; } public ServiceConnection getKeepAliveConnection() { return mKeepAliveConnection; } public void setKeepAliveConnection(ServiceConnection serviceConnection) { mKeepAliveConnection = serviceConnection; } public void setPredictionMetrics(String predictedUrl, long lastMayLaunchUrlTimestamp) { mPredictedUrl = predictedUrl; mLastMayLaunchUrlTimestamp = lastMayLaunchUrlTimestamp; } public String getPredictedUrl() { return mPredictedUrl; } public long getLastMayLaunchUrlTimestamp() { return mLastMayLaunchUrlTimestamp; } /** * @return Whether the default parameters are used for this session. */ public boolean isDefault() { return !mIgnoreFragments && !mShouldPrerenderOnCellular; } } private final Context mContext; private final Map<CustomTabsSessionToken, SessionParams> mSessionParams = new HashMap<>(); private final SparseBooleanArray mUidHasCalledWarmup = new SparseBooleanArray(); private boolean mWarmupHasBeenCalled = false; public ClientManager(Context context) { mContext = context.getApplicationContext(); RequestThrottler.loadInBackground(mContext); } /** Creates a new session. * * @param session Session provided by the client. * @param uid Client UID, as returned by Binder.getCallingUid(), * @param onDisconnect To be called on the UI thread when a client gets disconnected. * @return true for success. */ public boolean newSession( CustomTabsSessionToken session, int uid, DisconnectCallback onDisconnect) { if (session == null) return false; SessionParams params = new SessionParams(mContext, uid, onDisconnect); synchronized (this) { if (mSessionParams.containsKey(session)) return false; mSessionParams.put(session, params); } return true; } /** * Records that {@link CustomTabsConnection#warmup(long)} has been called from the given uid. */ public synchronized void recordUidHasCalledWarmup(int uid) { mWarmupHasBeenCalled = true; mUidHasCalledWarmup.put(uid, true); } /** Updates the client behavior stats and returns whether speculation is allowed. * * @param session Client session. * @param uid As returned by Binder.getCallingUid(). * @param url Predicted URL. * @return true if speculation is allowed. */ public synchronized boolean updateStatsAndReturnWhetherAllowed( CustomTabsSessionToken session, int uid, String url) { SessionParams params = mSessionParams.get(session); if (params == null || params.uid != uid) return false; params.setPredictionMetrics(url, SystemClock.elapsedRealtime()); RequestThrottler throttler = RequestThrottler.getForUid(mContext, uid); return throttler.updateStatsAndReturnWhetherAllowed(); } @VisibleForTesting synchronized int getWarmupState(CustomTabsSessionToken session) { SessionParams params = mSessionParams.get(session); boolean hasValidSession = params != null; boolean hasUidCalledWarmup = hasValidSession && mUidHasCalledWarmup.get(params.uid); int result = mWarmupHasBeenCalled ? NO_SESSION_WARMUP : NO_SESSION_NO_WARMUP; if (hasValidSession) { if (hasUidCalledWarmup) { result = SESSION_WARMUP; } else { result = mWarmupHasBeenCalled ? SESSION_NO_WARMUP_ALREADY_CALLED : SESSION_NO_WARMUP_NOT_CALLED; } } return result; } @VisibleForTesting synchronized int getPredictionOutcome(CustomTabsSessionToken session, String url) { SessionParams params = mSessionParams.get(session); if (params == null) return NO_PREDICTION; String predictedUrl = params.getPredictedUrl(); if (predictedUrl == null) return NO_PREDICTION; boolean urlsMatch = TextUtils.equals(predictedUrl, url) || (params.mIgnoreFragments && UrlUtilities.urlsMatchIgnoringFragments(predictedUrl, url)); return urlsMatch ? GOOD_PREDICTION : BAD_PREDICTION; } /** * Registers that a client has launched a URL inside a Custom Tab. */ public synchronized void registerLaunch(CustomTabsSessionToken session, String url) { int outcome = getPredictionOutcome(session, url); RecordHistogram.recordEnumeratedHistogram( "CustomTabs.PredictionStatus", outcome, PREDICTION_STATUS_COUNT); SessionParams params = mSessionParams.get(session); if (outcome == GOOD_PREDICTION) { long elapsedTimeMs = SystemClock.elapsedRealtime() - params.getLastMayLaunchUrlTimestamp(); RequestThrottler.getForUid(mContext, params.uid).registerSuccess( params.mPredictedUrl); RecordHistogram.recordCustomTimesHistogram("CustomTabs.PredictionToLaunch", elapsedTimeMs, 1, TimeUnit.MINUTES.toMillis(3), TimeUnit.MILLISECONDS, 100); } RecordHistogram.recordEnumeratedHistogram( "CustomTabs.WarmupStateOnLaunch", getWarmupState(session), SESSION_WARMUP_COUNT); if (params != null) params.setPredictionMetrics(null, 0); } /** * @return The referrer that is associated with the client owning given session. */ public synchronized Referrer getReferrerForSession(CustomTabsSessionToken session) { SessionParams params = mSessionParams.get(session); if (params == null) return null; final String packageName = params.packageName; return IntentHandler.constructValidReferrerForAuthority(packageName); } /** * @return The package name associated with the client owning the given session. */ public synchronized String getClientPackageNameForSession(CustomTabsSessionToken session) { SessionParams params = mSessionParams.get(session); return params == null ? null : params.packageName; } /** * @return The callback {@link CustomTabsSessionToken} for the given session. */ public synchronized CustomTabsCallback getCallbackForSession(CustomTabsSessionToken session) { return session != null ? session.getCallback() : null; } /** * @return Whether the urlbar should be hidden for the session on first page load. Urls are * foced to show up after the user navigates away. */ public synchronized boolean shouldHideDomainForSession(CustomTabsSessionToken session) { SessionParams params = mSessionParams.get(session); return params != null ? params.mShouldHideDomain : false; } /** * Sets whether the urlbar should be hidden for a given session. */ public synchronized void setHideDomainForSession(CustomTabsSessionToken session, boolean hide) { SessionParams params = mSessionParams.get(session); if (params != null) params.mShouldHideDomain = hide; } /** * @return Whether navigation info should be recorded and shared for the session. */ public synchronized boolean shouldSendNavigationInfoForSession(CustomTabsSessionToken session) { SessionParams params = mSessionParams.get(session); return params != null ? params.mShouldSendNavigationInfo : false; } /** * Sets whether navigation info should be recorded and shared for the session. */ public synchronized void setSendNavigationInfoForSession( CustomTabsSessionToken session, boolean save) { SessionParams params = mSessionParams.get(session); if (params != null) params.mShouldSendNavigationInfo = save; } /** * @return Whether the fragment should be ignored for prerender matching. */ public synchronized boolean getIgnoreFragmentsForSession(CustomTabsSessionToken session) { SessionParams params = mSessionParams.get(session); return params == null ? false : params.mIgnoreFragments; } /** Sets whether the fragment should be ignored for prerender matching. */ public synchronized void setIgnoreFragmentsForSession( CustomTabsSessionToken session, boolean value) { SessionParams params = mSessionParams.get(session); if (params != null) params.mIgnoreFragments = value; } /** * @return Whether prerender should be turned on for cellular networks for given session. */ public synchronized boolean shouldPrerenderOnCellularForSession( CustomTabsSessionToken session) { SessionParams params = mSessionParams.get(session); return params != null ? params.mShouldPrerenderOnCellular : false; } /** * @return Whether the session is using the default parameters (that is, * don't ignore fragments and don't prerender on cellular connections). */ public synchronized boolean usesDefaultSessionParameters(CustomTabsSessionToken session) { SessionParams params = mSessionParams.get(session); return params != null ? params.isDefault() : true; } /** * Sets whether prerender should be turned on for mobile networks for given session. */ public synchronized void setPrerenderCellularForSession( CustomTabsSessionToken session, boolean prerender) { SessionParams params = mSessionParams.get(session); if (params != null) params.mShouldPrerenderOnCellular = prerender; } /** Tries to bind to a client to keep it alive, and returns true for success. */ public synchronized boolean keepAliveForSession(CustomTabsSessionToken session, Intent intent) { // When an application is bound to a service, its priority is raised to // be at least equal to the application's one. This binds to a dummy // service (no calls to this service are made). if (intent == null || intent.getComponent() == null) return false; SessionParams params = mSessionParams.get(session); if (params == null) return false; String packageName = intent.getComponent().getPackageName(); PackageManager pm = mContext.getApplicationContext().getPackageManager(); // Only binds to the application associated to this session. if (!Arrays.asList(pm.getPackagesForUid(params.uid)).contains(packageName)) return false; Intent serviceIntent = new Intent().setComponent(intent.getComponent()); // This ServiceConnection doesn't handle disconnects. This is on // purpose, as it occurs when the remote process has died. Since the // only use of this connection is to keep the application alive, // re-connecting would just re-create the process, but the application // state has been lost at that point, the callbacks invalidated, etc. ServiceConnection connection = new ServiceConnection() { @Override public void onServiceConnected(ComponentName name, IBinder service) {} @Override public void onServiceDisconnected(ComponentName name) {} }; boolean ok; try { ok = mContext.bindService(serviceIntent, connection, Context.BIND_AUTO_CREATE); } catch (SecurityException e) { return false; } if (ok) params.setKeepAliveConnection(connection); return ok; } /** Unbind from the KeepAlive service for a client. */ public synchronized void dontKeepAliveForSession(CustomTabsSessionToken session) { SessionParams params = mSessionParams.get(session); if (params == null || params.getKeepAliveConnection() == null) return; ServiceConnection connection = params.getKeepAliveConnection(); params.setKeepAliveConnection(null); mContext.unbindService(connection); } /** See {@link RequestThrottler#isPrerenderingAllowed()} */ public synchronized boolean isPrerenderingAllowed(int uid) { return RequestThrottler.getForUid(mContext, uid).isPrerenderingAllowed(); } /** See {@link RequestThrottler#registerPrerenderRequest(String)} */ public synchronized void registerPrerenderRequest(int uid, String url) { RequestThrottler.getForUid(mContext, uid).registerPrerenderRequest(url); } /** See {@link RequestThrottler#reset()} */ public synchronized void resetThrottling(int uid) { RequestThrottler.getForUid(mContext, uid).reset(); } /** See {@link RequestThrottler#ban()} */ public synchronized void ban(int uid) { RequestThrottler.getForUid(mContext, uid).ban(); } /** * Cleans up all data associated with all sessions. */ public synchronized void cleanupAll() { List<CustomTabsSessionToken> sessions = new ArrayList<>(mSessionParams.keySet()); for (CustomTabsSessionToken session : sessions) cleanupSession(session); } /** * Handle any clean up left after a session is destroyed. * @param session The session that has been destroyed. */ public synchronized void cleanupSession(CustomTabsSessionToken session) { SessionParams params = mSessionParams.get(session); if (params == null) return; mSessionParams.remove(session); if (params.disconnectCallback != null) params.disconnectCallback.run(session); mUidHasCalledWarmup.delete(params.uid); } }