1 // Copyright 2015 The Chromium Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file.
4 
5 package org.chromium.customtabs.test;
6 
7 import android.annotation.SuppressLint;
8 import android.app.Activity;
9 import android.app.ActivityManager;
10 import android.content.ComponentName;
11 import android.content.Context;
12 import android.content.Intent;
13 import android.net.Uri;
14 import android.os.Build;
15 import android.os.Bundle;
16 import android.os.Debug;
17 import android.os.Handler;
18 import android.os.IBinder;
19 import android.os.Looper;
20 import android.os.SystemClock;
21 import android.util.Log;
22 import android.view.View;
23 import android.widget.Button;
24 import android.widget.CheckBox;
25 import android.widget.EditText;
26 import android.widget.RadioButton;
27 
28 import androidx.browser.customtabs.CustomTabsCallback;
29 import androidx.browser.customtabs.CustomTabsClient;
30 import androidx.browser.customtabs.CustomTabsIntent;
31 import androidx.browser.customtabs.CustomTabsServiceConnection;
32 import androidx.browser.customtabs.CustomTabsSession;
33 import androidx.core.app.BundleCompat;
34 
35 import java.lang.reflect.InvocationTargetException;
36 import java.lang.reflect.Method;
37 import java.util.ArrayList;
38 import java.util.HashSet;
39 import java.util.List;
40 import java.util.Random;
41 import java.util.Set;
42 
43 /** Activity used to benchmark Custom Tabs PLT.
44  *
45  * This activity contains benchmark code for two modes:
46  * 1. Comparison between a basic use of Custom Tabs and a basic use of WebView.
47  * 2. Custom Tabs benchmarking under various scenarios.
48  *
49  * The two modes are not merged into one as the metrics we can extract in the two cases
50  * are constrained for the first one by what WebView provides.
51  */
52 public class MainActivity extends Activity implements View.OnClickListener {
53     static final String TAG = "CUSTOMTABSBENCH";
54     static final String TAGCSV = "CUSTOMTABSBENCHCSV";
55     private static final String MEMORY_TAG = "CUSTOMTABSMEMORY";
56     private static final String DEFAULT_URL = "https://www.android.com";
57     private static final String DEFAULT_PACKAGE = "com.google.android.apps.chrome";
58     private static final int NONE = -1;
59     // Common key between the benchmark modes.
60     private static final String URL_KEY = "url";
61     private static final String PARALLEL_URL_KEY = "parallel_url";
62     private static final String DEFAULT_REFERRER_URL = "https://www.google.com";
63     // Keys for the WebView / Custom Tabs comparison.
64     static final String INTENT_SENT_EXTRA = "intent_sent_ms";
65     private static final String USE_WEBVIEW_KEY = "use_webview";
66     private static final String WARMUP_KEY = "warmup";
67 
68     // extraCommand related constants.
69     private static final String SET_PRERENDER_ON_CELLULAR = "setPrerenderOnCellularForSession";
70     private static final String SET_SPECULATION_MODE = "setSpeculationModeForSession";
71     private static final String SET_IGNORE_URL_FRAGMENTS_FOR_SESSION =
72             "setIgnoreUrlFragmentsForSession";
73 
74     private static final String ADD_VERIFIED_ORIGN = "addVerifiedOriginForSession";
75     private static final String ENABLE_PARALLEL_REQUEST = "enableParallelRequestForSession";
76     private static final String PARALLEL_REQUEST_REFERRER_KEY =
77             "android.support.customtabs.PARALLEL_REQUEST_REFERRER";
78     private static final String PARALLEL_REQUEST_URL_KEY =
79             "android.support.customtabs.PARALLEL_REQUEST_URL";
80     private static final int PARALLEL_REQUEST_MIN_DELAY_AFTER_WARMUP = 3000;
81 
82     private static final int NO_SPECULATION = 0;
83     private static final int PRERENDER = 2;
84     private static final int HIDDEN_TAB = 3;
85 
86     private final Handler mHandler = new Handler(Looper.getMainLooper());
87 
88     private EditText mUrlEditText;
89     private RadioButton mChromeRadioButton;
90     private RadioButton mWebViewRadioButton;
91     private CheckBox mWarmupCheckbox;
92     private CheckBox mParallelUrlCheckBox;
93     private EditText mParallelUrlEditText;
94     private long mIntentSentMs;
95 
96     @Override
onCreate(Bundle savedInstanceState)97     protected void onCreate(Bundle savedInstanceState) {
98         super.onCreate(savedInstanceState);
99         final Intent intent = getIntent();
100 
101         setUpUi();
102 
103         // Automated mode, 1s later to leave time for the app to settle.
104         if (intent.getStringExtra(URL_KEY) != null) {
105             mHandler.postDelayed(new Runnable() {
106                 @Override
107                 public void run() {
108                     processArguments(intent);
109                 }
110             }, 1000);
111         }
112     }
113 
114     /** Displays the UI and registers the click listeners. */
setUpUi()115     private void setUpUi() {
116         setContentView(R.layout.main);
117 
118         mUrlEditText = findViewById(R.id.url_text);
119         mChromeRadioButton = findViewById(R.id.radio_chrome);
120         mWebViewRadioButton = findViewById(R.id.radio_webview);
121         mWarmupCheckbox = findViewById(R.id.warmup_checkbox);
122         mParallelUrlCheckBox = findViewById(R.id.parallel_url_checkbox);
123         mParallelUrlEditText = findViewById(R.id.parallel_url_text);
124 
125         Button goButton = findViewById(R.id.go_button);
126 
127         mUrlEditText.setOnClickListener(this);
128         mChromeRadioButton.setOnClickListener(this);
129         mWebViewRadioButton.setOnClickListener(this);
130         mWarmupCheckbox.setOnClickListener(this);
131         mParallelUrlCheckBox.setOnClickListener(this);
132         mParallelUrlEditText.setOnClickListener(this);
133         goButton.setOnClickListener(this);
134     }
135 
136     @Override
onClick(View v)137     public void onClick(View v) {
138         int id = v.getId();
139 
140         boolean warmup = mWarmupCheckbox.isChecked();
141         boolean useChrome = mChromeRadioButton.isChecked();
142         boolean useWebView = mWebViewRadioButton.isChecked();
143         String url = mUrlEditText.getText().toString();
144         boolean willRequestParallelUrl = mParallelUrlCheckBox.isChecked();
145         String parallelUrl = null;
146         if (willRequestParallelUrl) {
147             parallelUrl = mParallelUrlEditText.getText().toString();
148         }
149 
150         if (id == R.id.go_button) {
151             customTabsWebViewBenchmark(url, useChrome, useWebView, warmup, parallelUrl);
152         }
153     }
154 
155     /** Routes to either of the benchmark modes. */
processArguments(Intent intent)156     private void processArguments(Intent intent) {
157         if (intent.hasExtra(USE_WEBVIEW_KEY)) {
158             startCustomTabsWebViewBenchmark(intent);
159         } else {
160             startCustomTabsBenchmark(intent);
161         }
162     }
163 
164     /** Start the CustomTabs / WebView comparison benchmark.
165      *
166      * NOTE: Methods below are for the first benchmark mode.
167      */
startCustomTabsWebViewBenchmark(Intent intent)168     private void startCustomTabsWebViewBenchmark(Intent intent) {
169         Bundle extras = intent.getExtras();
170         String url = extras.getString(URL_KEY);
171         String parallelUrl = extras.getString(PARALLEL_URL_KEY);
172         boolean useWebView = extras.getBoolean(USE_WEBVIEW_KEY);
173         boolean useChrome = !useWebView;
174         boolean warmup = extras.getBoolean(WARMUP_KEY);
175         customTabsWebViewBenchmark(url, useChrome, useWebView, warmup, parallelUrl);
176     }
177 
178     /** Start the CustomTabs / WebView comparison benchmark. */
customTabsWebViewBenchmark( String url, boolean useChrome, boolean useWebView, boolean warmup, String parallelUrl)179     private void customTabsWebViewBenchmark(
180             String url, boolean useChrome, boolean useWebView, boolean warmup, String parallelUrl) {
181         if (useChrome) {
182             launchChrome(url, warmup, parallelUrl);
183         } else {
184             assert useWebView;
185             launchWebView(url);
186         }
187     }
188 
launchWebView(String url)189     private void launchWebView(String url) {
190         Intent intent = new Intent();
191         intent.setData(Uri.parse(url));
192         intent.setClass(this, WebViewActivity.class);
193         intent.putExtra(INTENT_SENT_EXTRA, now());
194         startActivity(intent);
195     }
196 
launchChrome(String url, boolean warmup, String parallelUrl)197     private void launchChrome(String url, boolean warmup, String parallelUrl) {
198         CustomTabsServiceConnection connection = new CustomTabsServiceConnection() {
199             @Override
200             public void onCustomTabsServiceConnected(ComponentName name, CustomTabsClient client) {
201                 launchChromeIntent(url, warmup, client, parallelUrl);
202             }
203 
204             @Override
205             public void onServiceDisconnected(ComponentName name) {}
206         };
207         CustomTabsClient.bindCustomTabsService(this, DEFAULT_PACKAGE, connection);
208     }
209 
maybePrepareParallelUrlRequest(String parallelUrl, CustomTabsClient client, CustomTabsIntent intent, IBinder sessionBinder)210     private static void maybePrepareParallelUrlRequest(String parallelUrl, CustomTabsClient client,
211             CustomTabsIntent intent, IBinder sessionBinder) {
212         if (parallelUrl == null || parallelUrl.length() == 0) {
213             Log.w(TAG, "null or empty parallelUrl");
214             return;
215         }
216 
217         Uri parallelUri = Uri.parse(parallelUrl);
218         Bundle params = new Bundle();
219         BundleCompat.putBinder(params, "session", sessionBinder);
220 
221         Uri referrerUri = Uri.parse(DEFAULT_REFERRER_URL);
222         params.putParcelable("origin", referrerUri);
223 
224         Bundle result = client.extraCommand(ADD_VERIFIED_ORIGN, params);
225         boolean ok = (result != null) && result.getBoolean(ADD_VERIFIED_ORIGN);
226         if (!ok) throw new RuntimeException("Cannot add verified origin");
227 
228         result = client.extraCommand(ENABLE_PARALLEL_REQUEST, params);
229         ok = (result != null) && result.getBoolean(ENABLE_PARALLEL_REQUEST);
230         if (!ok) throw new RuntimeException("Cannot enable Parallel Request");
231         Log.w(TAG, "enabled Parallel Request");
232 
233         intent.intent.putExtra(PARALLEL_REQUEST_URL_KEY, parallelUri);
234         intent.intent.putExtra(PARALLEL_REQUEST_REFERRER_KEY, referrerUri);
235     }
236 
launchChromeIntent( String url, boolean warmup, CustomTabsClient client, String parallelUrl)237     private void launchChromeIntent(
238             String url, boolean warmup, CustomTabsClient client, String parallelUrl) {
239         CustomTabsCallback callback = new CustomTabsCallback() {
240             private long mNavigationStartOffsetMs;
241 
242             @Override
243             public void onNavigationEvent(int navigationEvent, Bundle extras) {
244                 long offsetMs = now() - mIntentSentMs;
245                 switch (navigationEvent) {
246                     case CustomTabsCallback.NAVIGATION_STARTED:
247                         mNavigationStartOffsetMs = offsetMs;
248                         Log.w(TAG, "navigationStarted = " + offsetMs);
249                         break;
250                     case CustomTabsCallback.NAVIGATION_FINISHED:
251                         Log.w(TAG, "navigationFinished = " + offsetMs);
252                         Log.w(TAG, "CHROME," + mNavigationStartOffsetMs + "," + offsetMs);
253                         break;
254                     default:
255                         break;
256                 }
257             }
258         };
259         CustomTabsSession session = client.newSession(callback);
260         final CustomTabsIntent customTabsIntent = new CustomTabsIntent.Builder(session).build();
261         final Uri uri = Uri.parse(url);
262 
263         IBinder sessionBinder = BundleCompat.getBinder(
264                 customTabsIntent.intent.getExtras(), CustomTabsIntent.EXTRA_SESSION);
265         assert sessionBinder != null;
266         maybePrepareParallelUrlRequest(parallelUrl, client, customTabsIntent, sessionBinder);
267 
268         if (warmup) {
269             client.warmup(0);
270             mHandler.postDelayed(new Runnable() {
271                 @Override
272                 public void run() {
273                     mIntentSentMs = now();
274                     customTabsIntent.launchUrl(MainActivity.this, uri);
275                 }
276             }, 3000);
277         } else {
278             mIntentSentMs = now();
279             customTabsIntent.launchUrl(MainActivity.this, uri);
280         }
281     }
282 
now()283     static long now() {
284         return System.currentTimeMillis();
285     }
286 
287     /**
288      * Holds the file and the range for pinning. Used only in the 'Pinning Benchmark' mode.
289      */
290     private static class PinInfo {
291         public boolean pinningBenchmark;
292         public String fileName;
293         public int offset;
294         public int length;
295 
PinInfo()296         public PinInfo() {}
297 
PinInfo(boolean pinningBenchmark, String fileName, int offset, int length)298         public PinInfo(boolean pinningBenchmark, String fileName, int offset, int length) {
299             this.pinningBenchmark = pinningBenchmark;
300             this.fileName = fileName;
301             this.offset = offset;
302             this.length = length;
303         }
304     }
305 
306     /**
307      * Holds immutable parameters of the benchmark that are not needed after launching an intent.
308      *
309      * There are a few parameters that need to be written to the CSV line, those better fit in the
310      * {@link CustomCallback}.
311      */
312     private static class LaunchInfo {
313         public final String url;
314         public final String speculatedUrl;
315         public final String parallelUrl;
316         public final int timeoutSeconds;
317 
LaunchInfo( String url, String speculatedUrl, String parallelUrl, int timeoutSeconds)318         public LaunchInfo(
319                 String url, String speculatedUrl, String parallelUrl, int timeoutSeconds) {
320             this.url = url;
321             this.speculatedUrl = speculatedUrl;
322             this.parallelUrl = parallelUrl;
323             this.timeoutSeconds = timeoutSeconds;
324         }
325     }
326 
327     /** Start the second benchmark mode.
328      *
329      * NOTE: Methods below are for the second mode.
330      */
startCustomTabsBenchmark(Intent intent)331     private void startCustomTabsBenchmark(Intent intent) {
332         String url = intent.getStringExtra(URL_KEY);
333         if (url == null) url = DEFAULT_URL;
334         String parallelUrl = intent.getStringExtra(PARALLEL_URL_KEY);
335 
336         String speculatedUrl = intent.getStringExtra("speculated_url");
337         if (speculatedUrl == null) speculatedUrl = url;
338         String packageName = intent.getStringExtra("package_name");
339         if (packageName == null) packageName = DEFAULT_PACKAGE;
340         boolean warmup = intent.getBooleanExtra("warmup", false);
341 
342         boolean skipLauncherActivity = intent.getBooleanExtra("skip_launcher_activity", false);
343         int delayToMayLaunchUrl = intent.getIntExtra("delay_to_may_launch_url", NONE);
344         int delayToLaunchUrl = intent.getIntExtra("delay_to_launch_url", NONE);
345         String speculationMode = intent.getStringExtra("speculation_mode");
346         if (speculationMode == null) speculationMode = "prerender";
347         int timeoutSeconds = intent.getIntExtra("timeout", NONE);
348 
349         PinInfo pinInfo;
350         if (!intent.getBooleanExtra("pinning_benchmark", false)) {
351             pinInfo = new PinInfo();
352         } else {
353             pinInfo = new PinInfo(true, intent.getStringExtra("pin_filename"),
354                     intent.getIntExtra("pin_offset", NONE), intent.getIntExtra("pin_length", NONE));
355         }
356         int extraBriefMemoryMb = intent.getIntExtra("extra_brief_memory_mb", 0);
357 
358         if (parallelUrl != null && !parallelUrl.equals("") && !warmup) {
359             if (pinInfo.pinningBenchmark) {
360                 String message = "Warming up while pinning is not interesting";
361                 Log.e(TAG, message);
362                 throw new RuntimeException(message);
363             }
364             Log.w(TAG, "Parallel URL provided, forcing warmup");
365             warmup = true;
366             delayToLaunchUrl = Math.max(delayToLaunchUrl, PARALLEL_REQUEST_MIN_DELAY_AFTER_WARMUP);
367             delayToMayLaunchUrl =
368                     Math.max(delayToMayLaunchUrl, PARALLEL_REQUEST_MIN_DELAY_AFTER_WARMUP);
369         }
370 
371         final CustomCallback cb =
372                 new CustomCallback(packageName, warmup, skipLauncherActivity, speculationMode,
373                         delayToMayLaunchUrl, delayToLaunchUrl, pinInfo, extraBriefMemoryMb);
374         launchCustomTabs(cb, new LaunchInfo(url, speculatedUrl, parallelUrl, timeoutSeconds));
375     }
376 
377     private final class CustomCallback extends CustomTabsCallback {
378         public final String packageName;
379         public final boolean warmup;
380         public final boolean skipLauncherActivity;
381         public final String speculationMode;
382         public final int delayToMayLaunchUrl;
383         public final int delayToLaunchUrl;
384         public boolean warmupCompleted;
385         public long intentSentMs = NONE;
386         public long pageLoadStartedMs = NONE;
387         public long pageLoadFinishedMs = NONE;
388         public long firstContentfulPaintMs = NONE;
389         public PinInfo pinInfo;
390         public long extraBriefMemoryMb;
391 
CustomCallback(String packageName, boolean warmup, boolean skipLauncherActivity, String speculationMode, int delayToMayLaunchUrl, int delayToLaunchUrl, PinInfo pinInfo, long extraBriefMemoryMb)392         public CustomCallback(String packageName, boolean warmup, boolean skipLauncherActivity,
393                 String speculationMode, int delayToMayLaunchUrl, int delayToLaunchUrl,
394                 PinInfo pinInfo, long extraBriefMemoryMb) {
395             this.packageName = packageName;
396             this.warmup = warmup;
397             this.skipLauncherActivity = skipLauncherActivity;
398             this.speculationMode = speculationMode;
399             this.delayToMayLaunchUrl = delayToMayLaunchUrl;
400             this.delayToLaunchUrl = delayToLaunchUrl;
401             this.pinInfo = pinInfo;
402             this.extraBriefMemoryMb = extraBriefMemoryMb;
403         }
404 
recordIntentHasBeenSent()405         public void recordIntentHasBeenSent() {
406             intentSentMs = SystemClock.uptimeMillis();
407         }
408 
409         @Override
onNavigationEvent(int navigationEvent, Bundle extras)410         public void onNavigationEvent(int navigationEvent, Bundle extras) {
411             switch (navigationEvent) {
412                 case CustomTabsCallback.NAVIGATION_STARTED:
413                     pageLoadStartedMs = SystemClock.uptimeMillis();
414                     break;
415                 case CustomTabsCallback.NAVIGATION_FINISHED:
416                     pageLoadFinishedMs = SystemClock.uptimeMillis();
417                     break;
418                 default:
419                     break;
420             }
421             if (allSet()) logMetricsAndFinish();
422         }
423 
424         @Override
extraCallback(String callbackName, Bundle args)425         public void extraCallback(String callbackName, Bundle args) {
426             if ("onWarmupCompleted".equals(callbackName)) {
427                 warmupCompleted = true;
428                 return;
429             }
430 
431             if (!"NavigationMetrics".equals(callbackName)) {
432                 Log.w(TAG, "Unknown extra callback skipped: " + callbackName);
433                 return;
434             }
435             long firstPaintMs = args.getLong("firstContentfulPaint", NONE);
436             long navigationStartMs = args.getLong("navigationStart", NONE);
437             if (firstPaintMs == NONE || navigationStartMs == NONE) return;
438             // Can be reported several times, only record the first one.
439             if (firstContentfulPaintMs == NONE) {
440                 firstContentfulPaintMs = navigationStartMs + firstPaintMs;
441             }
442             if (allSet()) logMetricsAndFinish();
443         }
444 
allSet()445         private boolean allSet() {
446             return intentSentMs != NONE && pageLoadStartedMs != NONE
447                     && firstContentfulPaintMs != NONE && pageLoadFinishedMs != NONE;
448         }
449 
450         /** Outputs the available metrics, and die. Unavalaible metrics are set to -1. */
logMetricsAndFinish()451         private void logMetricsAndFinish() {
452             String logLine = (warmup ? "1" : "0") + "," + (skipLauncherActivity ? "1" : "0") + ","
453                     + speculationMode + "," + delayToMayLaunchUrl + "," + delayToLaunchUrl + ","
454                     + intentSentMs + "," + pageLoadStartedMs + "," + pageLoadFinishedMs + ","
455                     + firstContentfulPaintMs;
456             if (pinInfo.pinningBenchmark) {
457                 logLine += ',' + extraBriefMemoryMb + ',' + pinInfo.length;
458             }
459             Log.w(TAGCSV, logLine);
460             logMemory(packageName, "AfterMetrics");
461             MainActivity.this.finish();
462         }
463 
464         /** Same as {@link #logMetricsAndFinish()} with a set delay in ms. */
logMetricsAndFinishDelayed(int delayMs)465         public void logMetricsAndFinishDelayed(int delayMs) {
466             mHandler.postDelayed(new Runnable() {
467                 @Override
468                 public void run() {
469                     logMetricsAndFinish();
470                 }
471             }, delayMs);
472         }
473     }
474 
475     /**
476      * Sums all the memory usage of a package, and returns (PSS, Private Dirty).
477      *
478      * Only works for packages where a service is exported by each process, which is the case for
479      * Chrome. Also, doesn't work on O and above, as
480      * {@link ActivityManager#getRunningServices(int)}} is restricted.
481      *
482      * @param context Application context
483      * @param packageName the package to query
484      * @return {pss, privateDirty} in kB, or null.
485      */
getPackagePssAndPrivateDirty(Context context, String packageName)486     private static int[] getPackagePssAndPrivateDirty(Context context, String packageName) {
487         if (Build.VERSION.SDK_INT > Build.VERSION_CODES.N_MR1) return null;
488 
489         ActivityManager am = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
490         List<ActivityManager.RunningServiceInfo> services = am.getRunningServices(1000);
491         if (services == null) return null;
492 
493         Set<Integer> pids = new HashSet<>();
494         for (ActivityManager.RunningServiceInfo info : services) {
495             if (packageName.equals(info.service.getPackageName())) pids.add(info.pid);
496         }
497 
498         int[] pidsArray = new int[pids.size()];
499         int i = 0;
500         for (int pid : pids) pidsArray[i++] = pid;
501         Debug.MemoryInfo infos[] = am.getProcessMemoryInfo(pidsArray);
502         if (infos == null || infos.length == 0) return null;
503 
504         int pss = 0;
505         int privateDirty = 0;
506         for (Debug.MemoryInfo info : infos) {
507             pss += info.getTotalPss();
508             privateDirty += info.getTotalPrivateDirty();
509         }
510 
511         return new int[] {pss, privateDirty};
512     }
513 
logMemory(String packageName, String message)514     private void logMemory(String packageName, String message) {
515         int[] pssAndPrivateDirty = getPackagePssAndPrivateDirty(
516                 getApplicationContext(), packageName);
517         if (pssAndPrivateDirty == null) return;
518         Log.w(MEMORY_TAG, message + "," + pssAndPrivateDirty[0] + "," + pssAndPrivateDirty[1]);
519     }
520 
forceSpeculationMode( CustomTabsClient client, IBinder sessionBinder, String speculationMode)521     private static void forceSpeculationMode(
522             CustomTabsClient client, IBinder sessionBinder, String speculationMode) {
523         // The same bundle can be used for all calls, as the commands only look for their own
524         // arguments in it.
525         Bundle params = new Bundle();
526         BundleCompat.putBinder(params, "session", sessionBinder);
527         params.putBoolean("ignoreFragments", true);
528         params.putBoolean("prerender", true);
529 
530         int speculationModeValue;
531         switch (speculationMode) {
532             case "disabled":
533                 speculationModeValue = NO_SPECULATION;
534                 break;
535             case "prerender":
536                 speculationModeValue = PRERENDER;
537                 break;
538             case "hidden_tab":
539                 speculationModeValue = HIDDEN_TAB;
540                 break;
541             default:
542                 throw new RuntimeException("Invalid speculation mode");
543         }
544         params.putInt("speculationMode", speculationModeValue);
545 
546         boolean ok = client.extraCommand(SET_PRERENDER_ON_CELLULAR, params) != null;
547         if (!ok) throw new RuntimeException("Cannot set cellular prerendering");
548         ok = client.extraCommand(SET_IGNORE_URL_FRAGMENTS_FOR_SESSION, params) != null;
549         if (!ok) throw new RuntimeException("Cannot set ignoreFragments");
550         ok = client.extraCommand(SET_SPECULATION_MODE, params) != null;
551         if (!ok) throw new RuntimeException("Cannot set the speculation mode");
552     }
553 
554     // Declare as public and volatile to prevent it from being optimized out.
555     private static volatile ArrayList<byte[]> sExtraArrays = new ArrayList<>();
556 
557     private static final int MAX_ALLOCATION_ALLOWED = 1 << 23; // 8 MiB
558 
createRandomlyFilledArray(int size, Random random)559     private static byte[] createRandomlyFilledArray(int size, Random random) {
560         // Fill in small chunks to avoid allocating 2x the size.
561         byte[] array = new byte[size];
562         final int chunkSize = 1 << 15; // 32 KiB
563         byte[] randomBytes = new byte[chunkSize];
564         for (int i = 0; i < size / chunkSize; i++) {
565             random.nextBytes(randomBytes);
566             System.arraycopy(randomBytes /* src */, 0 /* srcPos */, array /* dest */,
567                     i * chunkSize /* destPos */, chunkSize /* length */);
568         }
569         return array;
570     }
571 
572     // In order for this method to work, the Android system image needs to be modified to export
573     // PinnerService and allow any app to call pinRangeFromFile(). Usually pinning is requested by
574     // Chrome (in LibraryPrefetcher), but the call is reimplemented here to avoid restarting
575     // Chrome unnecessarily.
576     @SuppressLint("WrongConstant")
pinChrome(String fileName, int startOffset, int length)577     private boolean pinChrome(String fileName, int startOffset, int length) {
578         Context context = getApplicationContext();
579         Object pinner = context.getSystemService("pinner");
580         if (pinner == null) {
581             Log.w(TAG, "Cannot get PinnerService.");
582             return false;
583         }
584 
585         try {
586             Method pinRangeFromFile = pinner.getClass().getMethod(
587                     "pinRangeFromFile", String.class, int.class, int.class);
588             boolean ok = (Boolean) pinRangeFromFile.invoke(pinner, fileName, startOffset, length);
589             if (!ok) {
590                 Log.e(TAG, "Not allowed to call the method, should not happen");
591                 return false;
592             } else {
593                 Log.w(TAG, "Successfully pinned ordered code");
594             }
595         } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException ex) {
596             Log.w(TAG, "Error invoking the method. " + ex.getMessage());
597             return false;
598         }
599         return true;
600     }
601 
602     // In order for this method to work, the Android system image needs to be modified to export
603     // PinnerService and allow any app to call unpinChromeFiles().
604     @SuppressLint("WrongConstant")
unpinChrome()605     private boolean unpinChrome() {
606         Context context = getApplicationContext();
607         Object pinner = context.getSystemService("pinner");
608         if (pinner == null) {
609             Log.w(TAG, "Cannot get PinnerService for unpinning.");
610             return false;
611         }
612         try {
613             Method unpinChromeFiles = pinner.getClass().getMethod("unpinChromeFiles");
614             boolean ok = (Boolean) unpinChromeFiles.invoke(pinner);
615             if (!ok) {
616                 Log.e(TAG, "Could not make a reflection call to unpinChromeFiles()");
617                 return false;
618             } else {
619                 Log.i(TAG, "Unpinned Chrome files");
620             }
621         } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException ex) {
622             Log.w(TAG, "Error invoking the method. " + ex.getMessage());
623             return false;
624         }
625         return true;
626     }
627 
consumeExtraMemoryBriefly(long amountMb)628     private void consumeExtraMemoryBriefly(long amountMb) {
629         // Allocate memory and fill with random data. Randomization is needed to avoid efficient
630         // compression of the data in ZRAM.
631         Log.i(TAG, "Consuming extra memory (MiB) = " + amountMb);
632         int bytesToUse = (int) (amountMb * (1 << 20));
633         long beforeFill = SystemClock.uptimeMillis();
634         Random random = new Random();
635         do {
636             // Limit every allocation in size in case there is a per-allocation limit.
637             int size = bytesToUse < MAX_ALLOCATION_ALLOWED ? bytesToUse : MAX_ALLOCATION_ALLOWED;
638             bytesToUse -= MAX_ALLOCATION_ALLOWED;
639             sExtraArrays.add(createRandomlyFilledArray(size, random));
640         } while (bytesToUse > 0);
641         long afterFill = SystemClock.uptimeMillis() - beforeFill;
642         Log.i(TAG, "Time to fill extra memory (ms) = " + afterFill);
643 
644         // Allow a number of background apps to be killed.
645         int amountToWaitForBackgroundKilling = 3000;
646         syncSleepMs(amountToWaitForBackgroundKilling);
647 
648         // Free up memory to give Chrome the room to start without killing even more background
649         // apps.
650         sExtraArrays.clear();
651         System.gc();
652     }
653 
onCustomTabsServiceConnected( CustomTabsClient client, CustomCallback cb, LaunchInfo launchInfo)654     private void onCustomTabsServiceConnected(
655             CustomTabsClient client, CustomCallback cb, LaunchInfo launchInfo) {
656         logMemory(cb.packageName, "OnServiceConnected");
657 
658         final CustomTabsSession session = client.newSession(cb);
659         final CustomTabsIntent intent = (new CustomTabsIntent.Builder(session)).build();
660         IBinder sessionBinder =
661                 BundleCompat.getBinder(intent.intent.getExtras(), CustomTabsIntent.EXTRA_SESSION);
662         assert sessionBinder != null;
663         forceSpeculationMode(client, sessionBinder, cb.speculationMode);
664 
665         final Runnable launchRunnable = () -> {
666             logMemory(cb.packageName, "BeforeLaunch");
667 
668             if (cb.warmupCompleted) {
669                 maybePrepareParallelUrlRequest(
670                         launchInfo.parallelUrl, client, intent, sessionBinder);
671             } else {
672                 Log.e(TAG, "not warmed up yet!");
673             }
674 
675             intent.launchUrl(MainActivity.this, Uri.parse(launchInfo.url));
676             cb.recordIntentHasBeenSent();
677             if (launchInfo.timeoutSeconds != NONE) {
678                 cb.logMetricsAndFinishDelayed(launchInfo.timeoutSeconds * 1000);
679             }
680         };
681 
682         if (cb.pinInfo.pinningBenchmark) {
683             mHandler.post(launchRunnable); // Already waited for the delay.
684         } else {
685             if (cb.warmup) client.warmup(0);
686             if (cb.delayToMayLaunchUrl != NONE) {
687                 final Runnable mayLaunchRunnable = () -> {
688                     logMemory(cb.packageName, "BeforeMayLaunchUrl");
689                     session.mayLaunchUrl(Uri.parse(launchInfo.speculatedUrl), null, null);
690                     mHandler.postDelayed(launchRunnable, cb.delayToLaunchUrl);
691                 };
692                 mHandler.postDelayed(mayLaunchRunnable, cb.delayToMayLaunchUrl);
693             } else {
694                 mHandler.postDelayed(launchRunnable, cb.delayToLaunchUrl);
695             }
696         }
697     }
698 
syncSleepMs(int delay)699     private static void syncSleepMs(int delay) {
700         try {
701             Thread.sleep(delay);
702         } catch (InterruptedException e) {
703             Log.w(TAG, "Interrupted: " + e);
704         }
705     }
706 
continueWithServiceConnection( final CustomCallback cb, final LaunchInfo launchInfo)707     private void continueWithServiceConnection(
708             final CustomCallback cb, final LaunchInfo launchInfo) {
709         CustomTabsClient.bindCustomTabsService(
710                 this, cb.packageName, new CustomTabsServiceConnection() {
711                     @Override
712                     public void onCustomTabsServiceConnected(
713                             ComponentName name, final CustomTabsClient client) {
714                         MainActivity.this.onCustomTabsServiceConnected(client, cb, launchInfo);
715                     }
716 
717                     @Override
718                     public void onServiceDisconnected(ComponentName name) {}
719                 });
720     }
721 
launchCustomTabs(CustomCallback cb, LaunchInfo launchInfo)722     private void launchCustomTabs(CustomCallback cb, LaunchInfo launchInfo) {
723         final PinInfo pinInfo = cb.pinInfo;
724         if (!pinInfo.pinningBenchmark) {
725             continueWithServiceConnection(cb, launchInfo);
726         } else {
727             // Execute off the UI thread to allow slow operations like pinning or eating RAM for
728             // dinner.
729             new Thread(() -> {
730                 if (pinInfo.length > 0) {
731                     boolean ok = pinChrome(pinInfo.fileName, pinInfo.offset, pinInfo.length);
732                     if (!ok) throw new RuntimeException("Failed to pin Chrome file.");
733                 } else {
734                     boolean ok = unpinChrome();
735                     if (!ok) throw new RuntimeException("Failed to unpin Chrome file.");
736                 }
737                 // Pinning is async, wait until hopefully it finishes.
738                 syncSleepMs(3000);
739                 if (cb.extraBriefMemoryMb != 0) {
740                     consumeExtraMemoryBriefly(cb.extraBriefMemoryMb);
741                 }
742                 Log.i(TAG, "Waiting for " + cb.delayToLaunchUrl + "ms before launching URL");
743                 syncSleepMs(cb.delayToLaunchUrl);
744                 continueWithServiceConnection(cb, launchInfo);
745             }).start();
746         }
747     }
748 }
749