Merge "[ui] optimize sched subquery in process sorting"
diff --git a/Android.bp b/Android.bp
index a02d3ba..d068adb 100644
--- a/Android.bp
+++ b/Android.bp
@@ -3991,7 +3991,6 @@
         "protos/perfetto/metrics/android/hwui_metric.proto",
         "protos/perfetto/metrics/android/ion_metric.proto",
         "protos/perfetto/metrics/android/irq_runtime_metric.proto",
-        "protos/perfetto/metrics/android/jank_metric.proto",
         "protos/perfetto/metrics/android/java_heap_histogram.proto",
         "protos/perfetto/metrics/android/java_heap_stats.proto",
         "protos/perfetto/metrics/android/lmk_metric.proto",
@@ -4062,7 +4061,6 @@
         "protos/perfetto/metrics/android/hwui_metric.proto",
         "protos/perfetto/metrics/android/ion_metric.proto",
         "protos/perfetto/metrics/android/irq_runtime_metric.proto",
-        "protos/perfetto/metrics/android/jank_metric.proto",
         "protos/perfetto/metrics/android/java_heap_histogram.proto",
         "protos/perfetto/metrics/android/java_heap_stats.proto",
         "protos/perfetto/metrics/android/lmk_metric.proto",
@@ -8870,7 +8868,6 @@
         "src/trace_processor/metrics/sql/android/android_hwui_threads.sql",
         "src/trace_processor/metrics/sql/android/android_ion.sql",
         "src/trace_processor/metrics/sql/android/android_irq_runtime.sql",
-        "src/trace_processor/metrics/sql/android/android_jank.sql",
         "src/trace_processor/metrics/sql/android/android_lmk.sql",
         "src/trace_processor/metrics/sql/android/android_lmk_reason.sql",
         "src/trace_processor/metrics/sql/android/android_mem.sql",
diff --git a/BUILD b/BUILD
index db272f1..48b45b6 100644
--- a/BUILD
+++ b/BUILD
@@ -1176,7 +1176,6 @@
         "src/trace_processor/metrics/sql/android/android_hwui_threads.sql",
         "src/trace_processor/metrics/sql/android/android_ion.sql",
         "src/trace_processor/metrics/sql/android/android_irq_runtime.sql",
-        "src/trace_processor/metrics/sql/android/android_jank.sql",
         "src/trace_processor/metrics/sql/android/android_lmk.sql",
         "src/trace_processor/metrics/sql/android/android_lmk_reason.sql",
         "src/trace_processor/metrics/sql/android/android_mem.sql",
@@ -2948,7 +2947,6 @@
         "protos/perfetto/metrics/android/hwui_metric.proto",
         "protos/perfetto/metrics/android/ion_metric.proto",
         "protos/perfetto/metrics/android/irq_runtime_metric.proto",
-        "protos/perfetto/metrics/android/jank_metric.proto",
         "protos/perfetto/metrics/android/java_heap_histogram.proto",
         "protos/perfetto/metrics/android/java_heap_stats.proto",
         "protos/perfetto/metrics/android/lmk_metric.proto",
diff --git a/CHANGELOG b/CHANGELOG
index 9f94ffc..8407784 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -2,14 +2,27 @@
   Tracing service and probes:
     *
   Trace Processor:
-    * Removed enable_perfetto_x64_cpu_opt by default for x64 MacOS
-      since it caused issues for CIs.
+    *
   UI:
     *
   SDK:
     *
 
 
+v27.0 - 2022-07-01:
+  Tracing service and probes:
+    * Fix rare crash due to watchdog timeout being too short.
+  Trace Processor:
+    * Removed enable_perfetto_x64_cpu_opt by default for x64 MacOS
+      since it caused issues for CIs.
+    * Improved performance of filtering and sorting on most queries.
+  UI:
+    * Changed sorting of process groups to take slice count and presence of
+      perf profiles into account.
+  SDK:
+    * 
+
+
 v26.1 - 2022-06-13:
   Trace Processor:
     * Fixed build failures on Windows.
diff --git a/infra/ci/controller/Makefile b/infra/ci/controller/Makefile
index 66a159b..42e6194 100644
--- a/infra/ci/controller/Makefile
+++ b/infra/ci/controller/Makefile
@@ -31,7 +31,8 @@
 		--project ${PROJECT} -v ${GAE_VERSION} -s default -q
 
 lib/.stamp:
-	pip install -t lib/ oauth2client httplib2
+	echo "If this fails run `sudo apt install python-pip`"
+	python2.7 -m pip install -t lib/ rsa==4.0 oauth2client==4.1.3 httplib2==0.20.4
 	touch $@
 
 config.py: ../config.py
diff --git a/infra/ci/controller/controller.py b/infra/ci/controller/controller.py
index 7647fed..d5d2afe 100644
--- a/infra/ci/controller/controller.py
+++ b/infra/ci/controller/controller.py
@@ -318,6 +318,9 @@
     if '-ui-' in job_id:
       ui_links.append('https://storage.googleapis.com/%s/%s/ui/index.html' %
                       (GCS_ARTIFACTS, job_id))
+      ui_links.append(
+          'https://storage.googleapis.com/%s/%s/ui-test-artifacts/index.html' %
+          (GCS_ARTIFACTS, job_id))
     if job_obj['status'] == 'COMPLETED':
       passed_jobs.append(job_id)
     elif not job_config.get('SKIP_VOTING', False):
diff --git a/protos/perfetto/metrics/android/BUILD.gn b/protos/perfetto/metrics/android/BUILD.gn
index 467fcd7..2520d35 100644
--- a/protos/perfetto/metrics/android/BUILD.gn
+++ b/protos/perfetto/metrics/android/BUILD.gn
@@ -37,7 +37,6 @@
     "hwui_metric.proto",
     "ion_metric.proto",
     "irq_runtime_metric.proto",
-    "jank_metric.proto",
     "java_heap_histogram.proto",
     "java_heap_stats.proto",
     "lmk_metric.proto",
diff --git a/protos/perfetto/metrics/android/jank_metric.proto b/protos/perfetto/metrics/android/jank_metric.proto
deleted file mode 100644
index 9708adf..0000000
--- a/protos/perfetto/metrics/android/jank_metric.proto
+++ /dev/null
@@ -1,31 +0,0 @@
-/*
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-syntax = "proto2";
-
-package perfetto.protos;
-
-message AndroidJankMetrics {
-  repeated Warning warnings = 1;
-
-  message Warning {
-    optional int64 ts = 1;
-    optional int64 dur = 2;
-
-    optional string process_name = 3;
-    optional string warning_text = 4;
-  }
-}
\ No newline at end of file
diff --git a/protos/perfetto/metrics/metrics.proto b/protos/perfetto/metrics/metrics.proto
index b2d248a..5f9fe7d 100644
--- a/protos/perfetto/metrics/metrics.proto
+++ b/protos/perfetto/metrics/metrics.proto
@@ -33,7 +33,6 @@
 import "protos/perfetto/metrics/android/hwui_metric.proto";
 import "protos/perfetto/metrics/android/ion_metric.proto";
 import "protos/perfetto/metrics/android/irq_runtime_metric.proto";
-import "protos/perfetto/metrics/android/jank_metric.proto";
 import "protos/perfetto/metrics/android/java_heap_histogram.proto";
 import "protos/perfetto/metrics/android/java_heap_stats.proto";
 import "protos/perfetto/metrics/android/lmk_metric.proto";
@@ -177,8 +176,8 @@
   // Metric associated with hwcomposer.
   optional AndroidHwcomposerMetrics android_hwcomposer = 28;
 
-  // Detects common bad patterns that might lead to jank.
-  optional AndroidJankMetrics android_jank = 29;
+  // Deprecated was AndroidJankMetrics;
+  reserved 29;
 
   // G2D metrics.
   optional G2dMetrics g2d = 30;
diff --git a/protos/perfetto/metrics/perfetto_merged_metrics.proto b/protos/perfetto/metrics/perfetto_merged_metrics.proto
index 36c5dbb..ae9a804 100644
--- a/protos/perfetto/metrics/perfetto_merged_metrics.proto
+++ b/protos/perfetto/metrics/perfetto_merged_metrics.proto
@@ -628,21 +628,6 @@
 
 // End of protos/perfetto/metrics/android/irq_runtime_metric.proto
 
-// Begin of protos/perfetto/metrics/android/jank_metric.proto
-
-message AndroidJankMetrics {
-  repeated Warning warnings = 1;
-
-  message Warning {
-    optional int64 ts = 1;
-    optional int64 dur = 2;
-
-    optional string process_name = 3;
-    optional string warning_text = 4;
-  }
-}
-// End of protos/perfetto/metrics/android/jank_metric.proto
-
 // Begin of protos/perfetto/metrics/android/process_metadata.proto
 
 message AndroidProcessMetadata {
@@ -1718,8 +1703,8 @@
   // Metric associated with hwcomposer.
   optional AndroidHwcomposerMetrics android_hwcomposer = 28;
 
-  // Detects common bad patterns that might lead to jank.
-  optional AndroidJankMetrics android_jank = 29;
+  // Deprecated was AndroidJankMetrics;
+  reserved 29;
 
   // G2D metrics.
   optional G2dMetrics g2d = 30;
diff --git a/python/perfetto/trace_processor/metrics.descriptor b/python/perfetto/trace_processor/metrics.descriptor
index 5e99d7b..d4e2db6 100644
--- a/python/perfetto/trace_processor/metrics.descriptor
+++ b/python/perfetto/trace_processor/metrics.descriptor
Binary files differ
diff --git a/python/perfetto/trace_processor/metrics.descriptor.sha1 b/python/perfetto/trace_processor/metrics.descriptor.sha1
index 0789b59..41405e0 100644
--- a/python/perfetto/trace_processor/metrics.descriptor.sha1
+++ b/python/perfetto/trace_processor/metrics.descriptor.sha1
@@ -2,5 +2,5 @@
 // SHA1(tools/gen_binary_descriptors)
 // c4a38769074f8a8c2ffbf514b267919b5f2d47df
 // SHA1(protos/perfetto/metrics/metrics.proto)
-// 6a6df998653b26dabf7ae6591e2f8a8677669b77
+// b17e5a2952db164ae07fd3266bef5038be350762
   
\ No newline at end of file
diff --git a/src/android_internal/empty_file.cc b/src/android_internal/empty_file.cc
deleted file mode 100644
index 22ffa8f..0000000
--- a/src/android_internal/empty_file.cc
+++ /dev/null
@@ -1,21 +0,0 @@
-/*
- * Copyright (C) 2019 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-// TODO(primiano): this file is here only to have one translation unit for the
-// temporary perfetto_src_tracing_ipc target.
-
-__attribute__((visibility("default"))) void PerfettoNoOp();
-void PerfettoNoOp() {}
diff --git a/src/trace_processor/metrics/sql/BUILD.gn b/src/trace_processor/metrics/sql/BUILD.gn
index f00ad8c..52b7331 100644
--- a/src/trace_processor/metrics/sql/BUILD.gn
+++ b/src/trace_processor/metrics/sql/BUILD.gn
@@ -33,7 +33,6 @@
   "android/android_hwui_threads.sql",
   "android/android_ion.sql",
   "android/android_irq_runtime.sql",
-  "android/android_jank.sql",
   "android/android_lmk_reason.sql",
   "android/android_lmk.sql",
   "android/android_mem_unagg.sql",
diff --git a/src/trace_processor/metrics/sql/android/android_batt.sql b/src/trace_processor/metrics/sql/android/android_batt.sql
index 3de117b..2ee4530 100644
--- a/src/trace_processor/metrics/sql/android/android_batt.sql
+++ b/src/trace_processor/metrics/sql/android/android_batt.sql
@@ -85,11 +85,9 @@
 
 DROP TABLE IF EXISTS suspend_slice_;
 CREATE TABLE suspend_slice_ AS
--- TODO(simonmacm): remove trustworthy hard coding.
 SELECT
     ts,
-    dur,
-    true as trustworthy
+    dur
 FROM
     slice
     JOIN
@@ -98,6 +96,7 @@
 WHERE
     track.name = 'Suspend/Resume Latency'
     AND slice.name = 'syscore_resume(0)'
+    AND dur != -1
 ;
 
 SELECT RUN_METRIC('android/global_counter_span_view.sql',
@@ -168,6 +167,5 @@
       )
     )
     FROM suspend_slice_
-    WHERE trustworthy
   )
 );
diff --git a/src/trace_processor/metrics/sql/android/android_hwui_threads.sql b/src/trace_processor/metrics/sql/android/android_hwui_threads.sql
index ee6a3ab..f06a44d 100644
--- a/src/trace_processor/metrics/sql/android/android_hwui_threads.sql
+++ b/src/trace_processor/metrics/sql/android/android_hwui_threads.sql
@@ -21,7 +21,8 @@
     process.name as process_name,
     thread.utid
   FROM thread
-  JOIN {{process_allowlist_table}} process USING (upid)
+  JOIN {{process_allowlist_table}} process_allowlist USING (upid)
+  JOIN process USING (upid)
   WHERE thread.is_main_thread;
 
 DROP VIEW IF EXISTS {{table_name_prefix}}_render_thread;
@@ -30,7 +31,8 @@
     process.name as process_name,
     thread.utid
   FROM thread
-  JOIN {{process_allowlist_table}} process USING (upid)
+  JOIN {{process_allowlist_table}} process_allowlist USING (upid)
+  JOIN process USING (upid)
   WHERE thread.name = 'RenderThread';
 
 DROP VIEW IF EXISTS {{table_name_prefix}}_gpu_completion_thread;
@@ -39,7 +41,8 @@
     process.name as process_name,
     thread.utid
   FROM thread
-  JOIN {{process_allowlist_table}} process USING (upid)
+  JOIN {{process_allowlist_table}} process_allowlist USING (upid)
+  JOIN process USING (upid)
   WHERE thread.name = 'GPU completion';
 
 DROP VIEW IF EXISTS {{table_name_prefix}}_hwc_release_thread;
@@ -48,7 +51,8 @@
     process.name as process_name,
     thread.utid
   FROM thread
-  JOIN {{process_allowlist_table}} process USING (upid)
+  JOIN {{process_allowlist_table}} process_allowlist USING (upid)
+  JOIN process USING (upid)
   WHERE thread.name = 'HWC release';
 
 DROP TABLE IF EXISTS {{table_name_prefix}}_main_thread_slices;
diff --git a/src/trace_processor/metrics/sql/android/android_jank.sql b/src/trace_processor/metrics/sql/android/android_jank.sql
deleted file mode 100644
index 9c2cc22..0000000
--- a/src/trace_processor/metrics/sql/android/android_jank.sql
+++ /dev/null
@@ -1,284 +0,0 @@
---
--- Copyright 2020 The Android Open Source Project
---
--- Licensed under the Apache License, Version 2.0 (the "License");
--- you may not use this file except in compliance with the License.
--- You may obtain a copy of the License at
---
---     https://www.apache.org/licenses/LICENSE-2.0
---
--- Unless required by applicable law or agreed to in writing, software
--- distributed under the License is distributed on an "AS IS" BASIS,
--- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
--- See the License for the specific language governing permissions and
--- limitations under the License.
-
-
-DROP TABLE IF EXISTS android_jank_process_allowlist;
-CREATE TABLE android_jank_process_allowlist AS
-SELECT process.name, process.upid
-FROM process
-WHERE process.name IN (
-  'com.android.systemui',
-  'com.google.android.apps.nexuslauncher',
-  'com.google.android.inputmethod.latin'
-);
-
-SELECT RUN_METRIC(
-  'android/android_hwui_threads.sql',
-  'table_name_prefix', 'android_jank',
-  'process_allowlist_table', 'android_jank_process_allowlist');
-
-DROP TABLE IF EXISTS android_jank_thread_state_running;
-CREATE TABLE android_jank_thread_state_running AS
-SELECT utid, ts, dur, state
-FROM thread_state
-WHERE utid IN (SELECT utid FROM android_jank_main_thread_slices)
-AND state = 'Running'
-AND dur > 0;
-
-DROP TABLE IF EXISTS android_jank_thread_state_scheduled;
-CREATE TABLE android_jank_thread_state_scheduled AS
-SELECT utid, ts, dur, state
-FROM thread_state
-WHERE utid IN (SELECT utid FROM android_jank_main_thread_slices)
-AND (state = 'R' OR state = 'R+')
-AND dur > 0;
-
-DROP TABLE IF EXISTS android_jank_thread_state_io_wait;
-CREATE TABLE android_jank_thread_state_io_wait AS
-SELECT utid, ts, dur, state
-FROM thread_state
-WHERE utid IN (SELECT utid FROM android_jank_main_thread_slices)
-AND (((state = 'D' OR state = 'DK') AND io_wait) OR (state = 'DK' AND io_wait IS NULL))
-AND dur > 0;
-
---
--- Main Thread alerts
---
-
--- Expensive measure/layout
-
-DROP TABLE IF EXISTS android_jank_measure_layout_slices;
-CREATE TABLE android_jank_measure_layout_slices AS
-SELECT
-  process_name,
-  utid,
-  id,
-  ts,
-  dur
-FROM android_jank_main_thread_slices
-WHERE name in ('measure', 'layout')
-AND dur >= 3000000;
-
-CREATE VIRTUAL TABLE IF NOT EXISTS android_jank_measure_layout_slices_state
-USING span_join(android_jank_measure_layout_slices PARTITIONED utid, android_jank_thread_state_running PARTITIONED utid);
-
-DROP TABLE IF EXISTS android_jank_measure_layout_slices_high_cpu;
-CREATE TABLE android_jank_measure_layout_slices_high_cpu AS
-SELECT id FROM android_jank_measure_layout_slices_state
-GROUP BY id
-HAVING SUM(dur) > 3000000;
-
-DROP TABLE IF EXISTS android_jank_measure_layout_alerts;
-CREATE TABLE android_jank_measure_layout_alerts AS
-SELECT
-  process_name,
-  ts,
-  dur,
-  'Expensive measure/layout pass' as alert_name,
-  id
-FROM android_jank_measure_layout_slices
-JOIN android_jank_measure_layout_slices_high_cpu USING (id);
-
--- Inflation during ListView recycling
--- as additional alerts for expensive layout slices
-
-DROP TABLE IF EXISTS android_jank_listview_inflation_alerts;
-CREATE TABLE android_jank_listview_inflation_alerts AS
-SELECT
-  process_name,
-  ts,
-  dur,
-  'Inflation during ListView recycling' as alert_name
-FROM android_jank_main_thread_slices
-WHERE name IN ('obtainView', 'setupListItem')
-AND EXISTS (
-  SELECT 1
-  FROM descendant_slice(android_jank_main_thread_slices.id)
-  WHERE name = 'inflate')
-AND EXISTS(
-  SELECT 1
-  FROM android_jank_measure_layout_alerts
-  JOIN ancestor_slice(android_jank_main_thread_slices.id) USING (id)
-);
-
--- Long View#draw()
-
-DROP TABLE IF EXISTS android_jank_view_draw_slices;
-CREATE TABLE android_jank_view_draw_slices AS
-SELECT
-  process_name,
-  utid,
-  id,
-  ts,
-  dur
-FROM android_jank_main_thread_slices
-WHERE name in ('getDisplayList', 'Record View#draw()')
-AND dur >= 3000000;
-
-CREATE VIRTUAL TABLE IF NOT EXISTS android_jank_view_draw_slices_state
-USING span_join(android_jank_view_draw_slices PARTITIONED utid, android_jank_thread_state_running PARTITIONED utid);
-
-DROP TABLE IF EXISTS android_jank_view_draw_slices_high_cpu;
-CREATE TABLE android_jank_view_draw_slices_high_cpu AS
-SELECT id FROM android_jank_view_draw_slices_state
-GROUP BY id
-HAVING SUM(dur) > 3000000;
-
-DROP TABLE IF EXISTS android_jank_view_draw_alerts;
-CREATE TABLE android_jank_view_draw_alerts AS
-SELECT
-  process_name,
-  ts,
-  dur,
-  'Long View#draw()' as alert_name
-FROM android_jank_main_thread_slices
-JOIN android_jank_view_draw_slices_high_cpu USING (id);
-
--- Scheduling delay and Blocking I/O delay
-
-DROP TABLE IF EXISTS android_jank_long_do_frame_slices;
-CREATE TABLE android_jank_long_do_frame_slices AS
-SELECT
-  process_name,
-  utid,
-  id,
-  ts,
-  dur
-FROM android_jank_main_thread_slices
-WHERE name GLOB 'Choreographer#doFrame*'
-AND dur >= 5000000;
-
-CREATE VIRTUAL TABLE IF NOT EXISTS android_jank_do_frame_slices_state_scheduled
-USING span_join(android_jank_long_do_frame_slices PARTITIONED utid, android_jank_thread_state_scheduled PARTITIONED utid);
-
-
-DROP TABLE IF EXISTS android_jank_do_frame_slices_long_scheduled;
-CREATE TABLE android_jank_do_frame_slices_long_scheduled AS
-SELECT id FROM android_jank_do_frame_slices_state_scheduled
-GROUP BY id
-HAVING SUM(dur) > 5000000;
-
-DROP TABLE IF EXISTS android_jank_scheduling_delay_alerts;
-CREATE TABLE android_jank_scheduling_delay_alerts AS
-SELECT
-  process_name,
-  ts,
-  dur,
-  'Scheduling delay' as alert_name
-FROM android_jank_long_do_frame_slices
-JOIN android_jank_do_frame_slices_long_scheduled USING (id);
-
-CREATE VIRTUAL TABLE IF NOT EXISTS android_jank_do_frame_slices_state_io_wait
-USING span_join(android_jank_long_do_frame_slices PARTITIONED utid, android_jank_thread_state_io_wait PARTITIONED utid);
-
-DROP TABLE IF EXISTS android_jank_do_frame_slices_long_io_wait;
-CREATE TABLE android_jank_do_frame_slices_long_io_wait AS
-SELECT id FROM android_jank_do_frame_slices_state_io_wait
-GROUP BY id
-HAVING SUM(dur) > 5000000;
-
-DROP TABLE IF EXISTS android_jank_blocking_delay_alerts;
-CREATE TABLE android_jank_blocking_delay_alerts AS
-SELECT
-  process_name,
-  ts,
-  dur,
-  'Blocking I/O delay' as alert_name
-FROM android_jank_do_frame_slices
-JOIN android_jank_do_frame_slices_long_io_wait USING (id);
-
---
--- Render Thread alerts
---
-
--- Expensive Canvas#saveLayer()
-
-DROP TABLE IF EXISTS android_jank_save_layer_alerts;
-CREATE TABLE android_jank_save_layer_alerts AS
-SELECT
-  process_name,
-  ts,
-  dur,
-  'Expensive rendering with Canvas#saveLayer()' as alert_name
-FROM android_jank_render_thread_slices
-WHERE name GLOB '*alpha caused *saveLayer *'
-AND dur >= 1000000;
-
--- Path texture churn
-
-DROP TABLE IF EXISTS android_jank_generate_path_alerts;
-CREATE TABLE android_jank_generate_path_alerts AS
-SELECT
-  process_name,
-  ts,
-  dur,
-  'Path texture churn' as alert_name
-FROM android_jank_render_thread_slices
-WHERE name = 'Generate Path Texture'
-AND dur >= 3000000;
-
--- Expensive Bitmap uploads
-
-DROP TABLE IF EXISTS android_jank_upload_texture_alerts;
-CREATE TABLE android_jank_upload_texture_alerts AS
-SELECT
-  process_name,
-  ts,
-  dur,
-  'Expensive Bitmap uploads' as alert_name
-FROM android_jank_render_thread_slices
-WHERE name GLOB 'Upload *x* Texture'
-AND dur >= 3000000;
-
--- Merge all alerts tables into one table
-DROP TABLE IF EXISTS android_jank_alerts;
-CREATE TABLE android_jank_alerts AS
-SELECT process_name, ts, dur, alert_name FROM android_jank_measure_layout_alerts
-UNION ALL
-SELECT process_name, ts, dur, alert_name FROM android_jank_listview_inflation_alerts
-UNION ALL
-SELECT process_name, ts, dur, alert_name FROM android_jank_scheduling_delay_alerts
-UNION ALL
-SELECT process_name, ts, dur, alert_name FROM android_jank_blocking_delay_alerts
-UNION ALL
-SELECT process_name, ts, dur, alert_name FROM android_jank_save_layer_alerts
-UNION ALL
-SELECT process_name, ts, dur, alert_name FROM android_jank_generate_path_alerts
-UNION ALL
-SELECT process_name, ts, dur, alert_name FROM android_jank_upload_texture_alerts;
-
-DROP VIEW IF EXISTS android_jank_event;
-CREATE VIEW android_jank_event AS
-SELECT
-  'slice' as track_type,
-  process_name || ' warnings' as track_name,
-  ts,
-  0 as dur,
-  group_concat(alert_name) as slice_name
-FROM android_jank_alerts
-GROUP BY track_type, track_name, ts;
-
-DROP VIEW IF EXISTS android_jank_output;
-CREATE VIEW android_jank_output AS
-SELECT AndroidJankMetrics(
-  'warnings', (
-    SELECT RepeatedField(
-      AndroidJankMetrics_Warning(
-       'ts', ts,
-       'dur', dur,
-       'process_name', process_name,
-       'warning_text', alert_name))
-    FROM android_jank_alerts
-    ORDER BY process_name, ts, dur));
diff --git a/src/trace_processor/metrics/sql/android/android_startup.sql b/src/trace_processor/metrics/sql/android/android_startup.sql
index 14d9549..7c1a324 100644
--- a/src/trace_processor/metrics/sql/android/android_startup.sql
+++ b/src/trace_processor/metrics/sql/android/android_startup.sql
@@ -38,9 +38,6 @@
 -- Define helper functions for system state.
 SELECT RUN_METRIC('android/startup/system_state.sql');
 
--- Define process metadata functions.
-SELECT RUN_METRIC('android/process_metadata.sql');
-
 -- Returns the slices for forked processes. Never present in hot starts.
 -- Prefer this over process start_ts, since the process might have
 -- been preforked.
@@ -253,10 +250,14 @@
         WHERE thread_name = 'Jit thread pool'
       ),
       'other_processes_spawned_count', (
-        SELECT COUNT(1) FROM process
+        SELECT COUNT(1)
+        FROM process
         WHERE
-          (process.name IS NULL OR process.name != launches.package) AND
-          process.start_ts BETWEEN launches.ts AND launches.ts + launches.dur
+          process.start_ts BETWEEN launches.ts AND launches.ts + launches.dur AND
+          process.upid NOT IN (
+            SELECT upid FROM launch_processes
+            WHERE launch_processes.launch_id = launches.id
+          )
       )
     ),
     'hsc', NULL_IF_EMPTY(AndroidStartupMetric_HscMetrics(
diff --git a/src/trace_processor/metrics/sql/android/android_sysui_cuj.sql b/src/trace_processor/metrics/sql/android/android_sysui_cuj.sql
index 7acfd3e..60a2caf 100644
--- a/src/trace_processor/metrics/sql/android/android_sysui_cuj.sql
+++ b/src/trace_processor/metrics/sql/android/android_sysui_cuj.sql
@@ -15,29 +15,83 @@
 
 SELECT RUN_METRIC('android/process_metadata.sql');
 
+-- Stores information about the last CUJ (important UI transition) in the trace.
+-- There might be more than 1 CUJ in the trace and in that case we pick the one
+-- that finished last.
+-- This limiting to 1 CUJ is done to simplify the rest of the script.
 DROP TABLE IF EXISTS android_sysui_cuj_last_cuj;
 CREATE TABLE android_sysui_cuj_last_cuj AS
+-- Finds slices like J<SHADE_EXPAND_COLLAPSE> which mark which frames were
+-- rendered during a specific CUJ.
+  WITH cujs AS (
   SELECT
-    process.name AS name,
+    ROW_NUMBER() OVER (ORDER BY ts) AS cuj_id,
     process.upid AS upid,
+    process.name AS process_name,
     process_metadata.metadata AS process_metadata,
+    slice.name AS cuj_slice_name,
+    -- Extracts "CUJ_NAME" from "J<CUJ_NAME>"
     SUBSTR(slice.name, 3, LENGTH(slice.name) - 3) AS cuj_name,
     ts AS ts_start,
-    ts + dur AS ts_end,
-    dur AS dur
+    dur,
+    ts + dur AS ts_end
   FROM slice
-  JOIN process_track ON slice.track_id = process_track.id
+  JOIN process_track
+    ON slice.track_id = process_track.id
   JOIN process USING (upid)
   JOIN process_metadata USING (upid)
   WHERE
     slice.name GLOB 'J<*>'
-    -- Filter out CUJs that are <4ms long - assuming CUJ was cancelled.
-    AND slice.dur > 4e6
     AND (
       process.name GLOB 'com.google.android*'
       OR process.name GLOB 'com.android.*')
-  ORDER BY ts desc
-  LIMIT 1;
+),
+-- Slices logged from FrameTracker#markEvent that describe when
+-- the instrumentation was started and the reason the CUJ ended.
+cuj_state_markers AS (
+SELECT
+  cujs.cuj_id,
+  cuj_state_marker.ts,
+  cuj_state_marker.dur,
+  cuj_state_marker.name,
+  CASE
+    WHEN cuj_state_marker.name GLOB '*#FT#begin*' THEN 'begin'
+    WHEN cuj_state_marker.name GLOB '*#FT#deferMonitoring*' THEN 'deferMonitoring'
+    WHEN cuj_state_marker.name GLOB '*#FT#end*' THEN 'end'
+    WHEN cuj_state_marker.name GLOB '*#FT#cancel*' THEN 'cancel'
+    ELSE 'other'
+  END AS marker_type
+FROM cujs
+LEFT JOIN slice cuj_state_marker
+  ON cuj_state_marker.ts >= cujs.ts_start
+  AND cuj_state_marker.ts < cujs.ts_end
+  -- e.g. J<CUJ_NAME>#FT#end#0
+  AND cuj_state_marker.name GLOB (cujs.cuj_slice_name || "#FT#*")
+)
+SELECT
+  cujs.*,
+  CASE
+    WHEN EXISTS (
+      SELECT 1
+      FROM cuj_state_markers csm
+      WHERE csm.cuj_id = cujs.cuj_id
+      AND csm.marker_type = 'cancel')
+    THEN 'canceled'
+    WHEN EXISTS (
+      SELECT 1
+      FROM cuj_state_markers csm
+      WHERE csm.cuj_id = cujs.cuj_id
+      AND csm.marker_type = 'end')
+    THEN 'completed'
+  ELSE NULL
+  END AS state
+FROM cujs
+WHERE
+  state <> 'canceled'
+  -- Older builds don't have the state markers so we allow NULL but filter out
+  -- CUJs that are <4ms long - assuming CUJ was canceled in that case.
+  OR (state IS NULL AND cujs.dur > 4e6)
+ORDER BY ts_end DESC LIMIT 1;
 
 SELECT RUN_METRIC(
   'android/android_hwui_threads.sql',
@@ -124,7 +178,7 @@
 DROP VIEW IF EXISTS android_sysui_cuj_thread;
 CREATE VIEW android_sysui_cuj_thread AS
 SELECT
-  process.name as process_name,
+  process_name,
   thread.utid,
   thread.name
 FROM thread
diff --git a/src/trace_processor/metrics/sql/android/android_sysui_cuj_surfaceflinger.sql b/src/trace_processor/metrics/sql/android/android_sysui_cuj_surfaceflinger.sql
index 0fe7e2f..bd72bf1 100644
--- a/src/trace_processor/metrics/sql/android/android_sysui_cuj_surfaceflinger.sql
+++ b/src/trace_processor/metrics/sql/android/android_sysui_cuj_surfaceflinger.sql
@@ -59,7 +59,7 @@
 SELECT
   app_slice.name AS app_vsync,
   app_slice.id AS app_slice_id,
-  cuj_process.name AS app_process,
+  cuj_process.process_name AS app_process,
   sf_slice.name AS sf_vsync,
   sf_slice.id AS sf_slice_id
 FROM android_sysui_cuj_sf_actual_frame_timeline_slice sf_slice
diff --git a/src/trace_processor/metrics/sql/android/startup/launches.sql b/src/trace_processor/metrics/sql/android/startup/launches.sql
index 4337376..422042b 100644
--- a/src/trace_processor/metrics/sql/android/startup/launches.sql
+++ b/src/trace_processor/metrics/sql/android/startup/launches.sql
@@ -13,6 +13,9 @@
 -- See the License for the specific language governing permissions and
 -- limitations under the License.
 
+-- Define process metadata functions.
+SELECT RUN_METRIC('android/process_metadata.sql');
+
 -- The start of the launching event corresponds to the end of the AM handling
 -- the startActivity intent, whereas the end corresponds to the first frame drawn.
 -- Only successful app launches have a launching event.
@@ -104,8 +107,7 @@
       STARTUP_SLICE_COUNT(l.ts, l.ts_end, t.utid, 'activityStart') a_start,
       STARTUP_SLICE_COUNT(l.ts, l.ts_end, t.utid, 'activityResume') a_resume
     FROM launches l
-    LEFT JOIN package_list ON (l.package = package_list.package_name)
-    JOIN process p ON (l.package = p.name OR p.uid = package_list.uid)
+    JOIN process_metadata_table p ON (l.package = p.package_name)
     JOIN thread t ON (p.upid = t.upid AND t.is_main_thread)
   )
 )
diff --git a/src/trace_processor/metrics/sql/android/startup/mcycles_per_launch.sql b/src/trace_processor/metrics/sql/android/startup/mcycles_per_launch.sql
index 25c6809..44fb0aa 100644
--- a/src/trace_processor/metrics/sql/android/startup/mcycles_per_launch.sql
+++ b/src/trace_processor/metrics/sql/android/startup/mcycles_per_launch.sql
@@ -103,7 +103,7 @@
   '
     SELECT RepeatedField(process_name)
     FROM (
-      SELECT process.name AS process_name
+      SELECT IFNULL(process.name, "[NULL]") AS process_name
       FROM top_mcyles_process_excluding_started_per_launch
       JOIN process USING (upid)
       WHERE launch_id = $launch_id
diff --git a/src/trace_processor/tables/macros_internal.h b/src/trace_processor/tables/macros_internal.h
index 8454a55..00b375d 100644
--- a/src/trace_processor/tables/macros_internal.h
+++ b/src/trace_processor/tables/macros_internal.h
@@ -402,9 +402,9 @@
       PERFETTO_TP_PARENT_COLUMN_FLAG_NO_FLAG_COL)(__VA_ARGS__))
 
 // Creates the sparse vector with the given flags.
-#define PERFETTO_TP_TABLE_CONSTRUCTOR_SV(type, name, ...)          \
-  name##_ = ColumnStorage<TypedColumn<type>::stored_type>::Create< \
-      (name##_flags() & Column::Flag::kDense) != 0>();
+#define PERFETTO_TP_TABLE_CONSTRUCTOR_SV(type, name, ...)        \
+  name##_(ColumnStorage<TypedColumn<type>::stored_type>::Create< \
+          (name##_flags() & Column::Flag::kDense) != 0>()),
 
 // Invokes the chosen column constructor by passing the given args.
 #define PERFETTO_TP_TABLE_CONSTRUCTOR_COLUMN(type, name, ...)   \
@@ -755,20 +755,15 @@
     };                                                                        \
                                                                               \
     class_name(StringPool* pool, parent_class_name* parent)                   \
-        : macros_internal::MacroTable(pool, parent), parent_(parent) {        \
+        : macros_internal::MacroTable(pool, parent),                          \
+          PERFETTO_TP_TABLE_COLUMNS(DEF, PERFETTO_TP_TABLE_CONSTRUCTOR_SV)    \
+              parent_(parent) {                                               \
       PERFETTO_CHECK(kIsRootTable == (parent == nullptr));                    \
                                                                               \
       PERFETTO_TP_ALL_COLUMNS(DEF, PERFETTO_TP_TABLE_STATIC_ASSERT_FLAG)      \
                                                                               \
       /*                                                                      \
        * Expands to                                                           \
-       * col1_ = NullableVector<col1_type>(mode)                              \
-       * ...                                                                  \
-       */                                                                     \
-      PERFETTO_TP_TABLE_COLUMNS(DEF, PERFETTO_TP_TABLE_CONSTRUCTOR_SV);       \
-                                                                              \
-      /*                                                                      \
-       * Expands to                                                           \
        * columns_.emplace_back("col1", col1_, Column::kNoFlag, this,          \
        *                       static_cast<uint32_t>(columns_.size()),        \
        *                       static_cast<uint32_t>(row_maps_.size()) - 1);  \
@@ -934,14 +929,14 @@
       PERFETTO_TP_TABLE_COLUMNS(DEF, PERFETTO_TP_TABLE_CONSTRUCTOR_COLUMN);   \
     }                                                                         \
                                                                               \
-    parent_class_name* parent_ = nullptr;                                     \
-                                                                              \
     /*                                                                        \
      * Expands to                                                             \
      * NullableVector<col1_type> col1_;                                       \
      * ...                                                                    \
      */                                                                       \
     PERFETTO_TP_TABLE_COLUMNS(DEF, PERFETTO_TP_TABLE_MEMBER)                  \
+                                                                              \
+    parent_class_name* parent_ = nullptr;                                     \
   }
 
 }  // namespace trace_processor
diff --git a/test/trace_processor/graphics/android_jank.out b/test/trace_processor/graphics/android_jank.out
deleted file mode 100644
index 0bed674..0000000
--- a/test/trace_processor/graphics/android_jank.out
+++ /dev/null
@@ -1,38 +0,0 @@
-android_jank: {
-  warnings {
-    ts: 4000500
-    dur: 4999500
-    process_name: "com.android.systemui"
-    warning_text: "Expensive measure/layout pass"
-  }
-  warnings {
-    ts: 4001000
-    dur: 3499500
-    process_name: "com.android.systemui"
-    warning_text: "Inflation during ListView recycling"
-  }
-  warnings {
-    ts: 8000000
-    dur: 900000
-    process_name: "com.android.systemui"
-    warning_text: "Inflation during ListView recycling"
-  }
-  warnings {
-    ts: 1000000
-    dur: 19000000
-    process_name: "com.android.systemui"
-    warning_text: "Scheduling delay"
-  }
-  warnings {
-    ts: 116000000
-    dur: 1300000
-    process_name: "com.google.android.inputmethod.latin"
-    warning_text: "Expensive rendering with Canvas#saveLayer()"
-  }
-  warnings {
-    ts: 108000000
-    dur: 4000000
-    process_name: "com.google.android.inputmethod.latin"
-    warning_text: "Expensive Bitmap uploads"
-  }
-}
diff --git a/test/trace_processor/graphics/android_jank.py b/test/trace_processor/graphics/android_jank.py
deleted file mode 100644
index ba6a628..0000000
--- a/test/trace_processor/graphics/android_jank.py
+++ /dev/null
@@ -1,110 +0,0 @@
-#!/usr/bin/env python3
-# Copyright (C) 2020 The Android Open Source Project
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-#      http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-from os import sys, path
-
-import synth_common
-
-
-def add_main_thread_atrace(trace, ts, ts_end, buf):
-  trace.add_atrace_begin(ts=ts, tid=PID, pid=PID, buf=buf)
-  trace.add_atrace_end(ts=ts_end, tid=PID, pid=PID)
-
-
-def add_render_thread_atrace(trace, ts, ts_end, buf):
-  trace.add_atrace_begin(ts=ts, tid=RTID, pid=PID, buf=buf)
-  trace.add_atrace_end(ts=ts_end, tid=RTID, pid=PID)
-
-
-trace = synth_common.create_trace()
-
-trace.add_packet()
-trace.add_package_list(
-    ts=0, name="com.android.systemui", uid=10001, version_code=1)
-trace.add_package_list(
-    ts=0,
-    name="com.google.android.inputmethod.latin",
-    uid=10002,
-    version_code=1)
-
-trace.add_process(pid=1000, ppid=1, cmdline="com.android.systemui", uid=10001)
-trace.add_thread(
-    tid=1001, tgid=1000, cmdline="RenderThread", name="RenderThread")
-trace.add_process(
-    pid=2000, ppid=1, cmdline="com.google.android.inputmethod.latin", uid=10002)
-trace.add_thread(
-    tid=2001, tgid=2000, cmdline="RenderThread", name="RenderThread")
-
-trace.add_ftrace_packet(cpu=0)
-
-# com.android.systemui
-
-trace.add_atrace_begin(
-    ts=1_000_000, tid=1000, pid=1000, buf='Choreographer#doFrame')
-trace.add_atrace_begin(ts=1_000_100, tid=1000, pid=1000, buf='traversal')
-trace.add_atrace_begin(ts=1_000_500, tid=1000, pid=1000, buf='measure')
-trace.add_atrace_end(ts=4_000_000, tid=1000, pid=1000)
-trace.add_atrace_begin(ts=4_000_500, tid=1000, pid=1000, buf='layout')
-trace.add_atrace_begin(ts=4_001_000, tid=1000, pid=1000, buf='setupListItem')
-trace.add_atrace_begin(ts=4_500_000, tid=1000, pid=1000, buf='inflate')
-trace.add_atrace_end(ts=5_500_000, tid=1000, pid=1000)
-trace.add_atrace_begin(ts=6_500_000, tid=1000, pid=1000, buf='inflate')
-trace.add_atrace_end(ts=7_500_000, tid=1000, pid=1000)
-trace.add_atrace_end(ts=7_500_500, tid=1000, pid=1000)
-trace.add_atrace_begin(ts=8_000_000, tid=1000, pid=1000, buf='obtainView')
-trace.add_atrace_begin(ts=8_000_100, tid=1000, pid=1000, buf='inflate')
-trace.add_atrace_end(ts=8_500_000, tid=1000, pid=1000)
-trace.add_atrace_end(ts=8_900_000, tid=1000, pid=1000)
-trace.add_atrace_end(ts=9_000_000, tid=1000, pid=1000)
-trace.add_atrace_end(ts=9_000_000, tid=1000, pid=1000)
-trace.add_atrace_end(ts=20_000_000, tid=1000, pid=1000)
-
-trace.add_sched(ts=1_000_000, prev_pid=0, next_pid=1000)
-trace.add_sched(ts=10_000_000, prev_pid=1000, next_pid=0, prev_state='R')
-trace.add_sched(ts=10_500_000, prev_pid=0, next_pid=0)
-trace.add_sched(ts=19_500_000, prev_pid=0, next_pid=1000)
-trace.add_sched(ts=20_500_000, prev_pid=1000, next_pid=0, prev_state='R')
-
-# com.google.android.inputmethod.latin
-
-trace.add_atrace_begin(
-    ts=101_000_000, tid=2000, pid=2000, buf='Choreographer#doFrame')
-trace.add_atrace_begin(ts=101_000_100, tid=2000, pid=2000, buf='traversal')
-trace.add_atrace_begin(ts=101_000_500, tid=2000, pid=2000, buf='measure')
-trace.add_atrace_end(ts=104_000_000, tid=2000, pid=2000)
-trace.add_atrace_begin(ts=104_000_500, tid=2000, pid=2000, buf='layout')
-trace.add_atrace_end(ts=105_000_000, tid=2000, pid=2000)
-trace.add_atrace_end(ts=105_000_000, tid=2000, pid=2000)
-trace.add_atrace_begin(ts=105_000_000, tid=2000, pid=2000, buf='draw')
-trace.add_atrace_end(ts=119_000_000, tid=2000, pid=2000)
-trace.add_atrace_end(ts=120_000_000, tid=2000, pid=2000)
-
-trace.add_atrace_begin(ts=105_000_000, tid=2001, pid=2000, buf='DrawFrames 3')
-trace.add_atrace_begin(
-    ts=108_000_000, tid=2001, pid=2000, buf='Upload 300x300 Texture')
-trace.add_atrace_end(ts=112_000_000, tid=2001, pid=2000)
-trace.add_atrace_begin(
-    ts=116_000_000,
-    tid=2001,
-    pid=2000,
-    buf='alpha caused unclipped saveLayer 201x319')
-trace.add_atrace_end(ts=117_300_000, tid=2001, pid=2000)
-trace.add_atrace_end(ts=118_000_000, tid=2001, pid=2000)
-
-trace.add_sched(ts=101_000_000, prev_pid=0, next_pid=2000)
-trace.add_sched(ts=120_000_000, prev_pid=2000, next_pid=0, prev_state='R')
-trace.add_sched(ts=120_500_000, prev_pid=0, next_pid=0)
-
-sys.stdout.buffer.write(trace.trace.SerializeToString())
diff --git a/test/trace_processor/graphics/android_sysui_cuj.py b/test/trace_processor/graphics/android_sysui_cuj.py
index 1845aa7..b1f3bd8 100644
--- a/test/trace_processor/graphics/android_sysui_cuj.py
+++ b/test/trace_processor/graphics/android_sysui_cuj.py
@@ -125,9 +125,15 @@
 trace.add_thread(
     tid=JITID, tgid=PID, cmdline="Jit thread pool", name="Jit thread pool")
 trace.add_ftrace_packet(cpu=0)
+trace.add_atrace_async_begin(ts=5, tid=PID, pid=PID, buf="J<SHOULD_BE_IGNORED>")
 trace.add_atrace_async_begin(ts=10, tid=PID, pid=PID, buf="J<SHADE_ROW_EXPAND>")
 trace.add_atrace_async_end(
+    ts=100_000_000, tid=PID, pid=PID, buf="J<SHOULD_BE_IGNORED>")
+trace.add_atrace_async_begin(
+    ts=100_100_000, tid=PID, pid=PID, buf="J<CANCELED>")
+trace.add_atrace_async_end(
     ts=901_000_010, tid=PID, pid=PID, buf="J<SHADE_ROW_EXPAND>")
+trace.add_atrace_async_end(ts=999_000_000, tid=PID, pid=PID, buf="J<CANCELED>")
 
 add_frame(
     trace,
@@ -350,6 +356,9 @@
     ts_gpu=1_400_000_000,
     ts_end_gpu=1_500_000_000)
 
+add_main_thread_atrace(
+    trace, ts=990_000_000, ts_end=995_000_000, buf="J<CANCELED>#FT#cancel#0")
+
 add_expected_frame_events(ts=0, dur=16_000_000, token_start=10)
 add_actual_frame_events(ts=0, dur=16_000_000, token_start=10)
 
diff --git a/test/trace_processor/graphics/index b/test/trace_processor/graphics/index
index 4b396e0..56a4a93 100644
--- a/test/trace_processor/graphics/index
+++ b/test/trace_processor/graphics/index
@@ -38,9 +38,6 @@
 # Composition layer
 composition_layer.py composition_layer_count_test.sql composition_layer_count.out
 
-# Android Jank metrics
-android_jank.py android_jank android_jank.out
-
 # G2D metrics
 # TODO(rsavitski): find a real trace and double-check that the textproto is
 # realistic. One kernel's source I checked had tgid=0 for all counter events.
diff --git a/test/trace_processor/startup/android_startup_attribution.out b/test/trace_processor/startup/android_startup_attribution.out
index 87fd0ee..e2135ce 100644
--- a/test/trace_processor/startup/android_startup_attribution.out
+++ b/test/trace_processor/startup/android_startup_attribution.out
@@ -47,6 +47,17 @@
     activity_hosting_process_count: 1
     process {
       name: "com.some.app"
+      uid: 10001
+      package {
+        package_name: "com.some.app"
+        apk_version_code: 123
+        debuggable: false
+      }
+      packages_for_uid {
+        package_name: "com.some.app"
+        apk_version_code: 123
+        debuggable: false
+      }
     }
     event_timestamps {
       intent_received: 100
diff --git a/test/trace_processor/startup/android_startup_attribution.py b/test/trace_processor/startup/android_startup_attribution.py
index 2f8a4be..a12601b 100644
--- a/test/trace_processor/startup/android_startup_attribution.py
+++ b/test/trace_processor/startup/android_startup_attribution.py
@@ -34,7 +34,7 @@
 trace.add_packet()
 trace.add_process(1, 0, 'init')
 trace.add_process(SYSTEM_SERVER_PID, 1, 'system_server')
-trace.add_process(APP_PID, 1, 'com.some.app')
+trace.add_process(APP_PID, 1, 'com.some.app', uid=10001)
 trace.add_thread(tid=SECOND_APP_TID, tgid=APP_PID, cmdline='second_thread')
 trace.add_thread(
     tid=JIT_TID,
@@ -48,6 +48,8 @@
 trace.add_thread(tid=BINDER_TID, tgid=APP_PID, cmdline='Binder', name='Binder')
 trace.add_thread(tid=FONTS_TID, tgid=APP_PID, cmdline='fonts', name='fonts')
 
+trace.add_package_list(ts=99, name='com.some.app', uid=10001, version_code=123)
+
 trace.add_ftrace_packet(cpu=0)
 # Start intent.
 trace.add_atrace_begin(
diff --git a/test/trace_processor/startup/android_startup_breakdown.out b/test/trace_processor/startup/android_startup_breakdown.out
index 446c5f1..ff9f062 100644
--- a/test/trace_processor/startup/android_startup_breakdown.out
+++ b/test/trace_processor/startup/android_startup_breakdown.out
@@ -57,6 +57,17 @@
     activity_hosting_process_count: 1
     process {
       name: "com.google.android.calendar"
+      uid: 10001
+      package {
+        package_name: "com.google.android.calendar"
+        apk_version_code: 123
+        debuggable: false
+      }
+      packages_for_uid {
+        package_name: "com.google.android.calendar"
+        apk_version_code: 123
+        debuggable: false
+      }
     }
     activities {
       name: "com.google.android.calendar.MainActivity"
diff --git a/test/trace_processor/startup/android_startup_breakdown.py b/test/trace_processor/startup/android_startup_breakdown.py
index 29fdd2c..7ea4256 100644
--- a/test/trace_processor/startup/android_startup_breakdown.py
+++ b/test/trace_processor/startup/android_startup_breakdown.py
@@ -26,7 +26,10 @@
 trace.add_packet()
 trace.add_process(1, 0, 'init')
 trace.add_process(2, 1, 'system_server')
-trace.add_process(3, 1, 'com.google.android.calendar')
+trace.add_process(3, 1, 'com.google.android.calendar', uid=10001)
+
+trace.add_package_list(
+    ts=100, name='com.google.android.calendar', uid=10001, version_code=123)
 
 trace.add_ftrace_packet(cpu=0)
 
diff --git a/test/trace_processor/startup/android_startup_process_track.out b/test/trace_processor/startup/android_startup_process_track.out
index 328651a..4076197 100644
--- a/test/trace_processor/startup/android_startup_process_track.out
+++ b/test/trace_processor/startup/android_startup_process_track.out
@@ -2,7 +2,7 @@
   startup {
     startup_id: 1
     package_name: "com.google.android.calendar"
-    process_name: "com.google.android.calendar"
+    process_name: "com.google.android.calendar:debug"
     zygote_new_process: false
     to_first_frame {
       dur_ns: 7
@@ -37,7 +37,18 @@
     }
     activity_hosting_process_count: 1
     process {
-      name: "com.google.android.calendar"
+      name: "com.google.android.calendar:debug"
+      uid: 10001
+      package {
+        package_name: "com.google.android.calendar"
+        apk_version_code: 123
+        debuggable: false
+      }
+      packages_for_uid {
+        package_name: "com.google.android.calendar"
+        apk_version_code: 123
+        debuggable: false
+      }
     }
     event_timestamps {
       intent_received: 100
@@ -89,6 +100,17 @@
     activity_hosting_process_count: 1
     process {
       name: "com.google.android.calendar"
+      uid: 10001
+      package {
+        package_name: "com.google.android.calendar"
+        apk_version_code: 123
+        debuggable: false
+      }
+      packages_for_uid {
+        package_name: "com.google.android.calendar"
+        apk_version_code: 123
+        debuggable: false
+      }
     }
     event_timestamps {
       intent_received: 200
diff --git a/test/trace_processor/startup/android_startup_process_track.py b/test/trace_processor/startup/android_startup_process_track.py
index f24a429..4653836 100644
--- a/test/trace_processor/startup/android_startup_process_track.py
+++ b/test/trace_processor/startup/android_startup_process_track.py
@@ -55,10 +55,21 @@
 # (i.e. process exit is taken into account).
 trace = synth_common.create_trace()
 trace.add_packet()
-trace.add_process(1, 0, 'init')
-trace.add_process(2, 1, 'system_server')
+trace.add_process(1, 0, 'init', uid=10001)
+trace.add_process(2, 1, 'system_server', uid=1000)
+
+trace.add_package_list(
+    ts=99, name='com.google.android.calendar', uid=10001, version_code=123)
+
 add_startup(trace, ts=100, pid=3)
+trace.add_packet(ts=140)
+trace.add_process(3, 1, 'com.google.android.calendar:debug', uid=10001)
+
+trace.add_packet()
 trace.add_process_free(ts=150, tid=3, comm='', prio=0)
+
 add_startup(trace, ts=200, pid=4)
+trace.add_packet(ts=250)
+trace.add_process(4, 1, 'com.google.android.calendar', uid=10001)
 
 sys.stdout.buffer.write(trace.trace.SerializeToString())
diff --git a/ui/build.js b/ui/build.js
index 758dee6..8a2ad50 100644
--- a/ui/build.js
+++ b/ui/build.js
@@ -100,6 +100,7 @@
   outDir: pjoin(ROOT_DIR, 'out/ui'),
   version: '',  // v1.2.3, derived from the CHANGELOG + git.
   outUiDir: '',
+  outUiTestArtifactsDir: '',
   outDistRootDir: '',
   outTscDir: '',
   outGenDir: '',
@@ -115,6 +116,10 @@
   {r: /ui\/src\/assets\/.+[.]scss/, f: compileScss},
   {r: /ui\/src\/assets\/.+[.]scss/, f: compileScss},
   {r: /ui\/src\/chrome_extension\/.*/, f: copyExtensionAssets},
+  {
+    r: /ui\/src\/test\/diff_viewer\/(.+[.](?:html|js))/,
+    f: copyUiTestArtifactsAssets,
+  },
   {r: /.*\/dist\/.+\/(?!manifest\.json).*/, f: genServiceWorkerManifestJson},
   {r: /.*\/dist\/.*/, f: notifyLiveServer},
 ];
@@ -150,6 +155,7 @@
   const clean = !args.no_build;
   cfg.outDir = path.resolve(ensureDir(args.out || cfg.outDir));
   cfg.outUiDir = ensureDir(pjoin(cfg.outDir, 'ui'), clean);
+  cfg.outUiTestArtifactsDir = ensureDir(pjoin(cfg.outDir, 'ui-test-artifacts'));
   cfg.outExtDir = ensureDir(pjoin(cfg.outUiDir, 'chrome_extension'));
   cfg.outDistRootDir = ensureDir(pjoin(cfg.outUiDir, 'dist'));
   const proc = exec('python3', [VERSION_SCRIPT, '--stdout'], {stdout: 'pipe'});
@@ -219,6 +225,7 @@
     buildWasm(args.no_wasm);
     scanDir('ui/src/assets');
     scanDir('ui/src/chrome_extension');
+    scanDir('ui/src/test/diff_viewer');
     scanDir('buildtools/typefaces');
     scanDir('buildtools/catapult_trace_viewer');
     generateImports('ui/src/tracks', 'all_tracks.ts');
@@ -306,6 +313,10 @@
   addTask(cp, [src, pjoin(cfg.outDistDir, 'assets', dst)]);
 }
 
+function copyUiTestArtifactsAssets(src, dst) {
+  addTask(cp, [src, pjoin(cfg.outUiTestArtifactsDir, dst)]);
+}
+
 function compileScss() {
   const src = pjoin(ROOT_DIR, 'ui/src/assets/perfetto.scss');
   const dst = pjoin(cfg.outDistDir, 'perfetto.css');
diff --git a/ui/src/common/recordingV2/recording_config_utils.ts b/ui/src/common/recordingV2/recording_config_utils.ts
index fc95007..85495e8 100644
--- a/ui/src/common/recordingV2/recording_config_utils.ts
+++ b/ui/src/common/recordingV2/recording_config_utils.ts
@@ -71,7 +71,7 @@
 export function genTraceConfig(
     uiCfg: RecordConfig, targetInfo: TargetInfo): TraceConfig {
   const androidApiLevel = (targetInfo.targetType === 'ANDROID') ?
-      targetInfo.dynamicTargetInfo?.androidApiLevel :
+      targetInfo.androidApiLevel :
       undefined;
   const protoCfg = new TraceConfig();
   protoCfg.durationMs = uiCfg.durationMs;
diff --git a/ui/src/common/recordingV2/recording_interfaces_v2.ts b/ui/src/common/recordingV2/recording_interfaces_v2.ts
index 3193a3e..823802e 100644
--- a/ui/src/common/recordingV2/recording_interfaces_v2.ts
+++ b/ui/src/common/recordingV2/recording_interfaces_v2.ts
@@ -39,24 +39,41 @@
   connectNewTarget(): Promise<RecordingTargetV2>;
 }
 
-export interface DynamicTargetInfo {
+export interface DataSource {
+  name: string;
+
+  // Contains information that is opaque to the recording code. The caller can
+  // use the DataSource name to type cast the DataSource descriptor.
+  // For targets calling QueryServiceState, 'descriptor' will hold the
+  // datasource descriptor:
+  // https://source.corp.google.com/android/external/perfetto/protos/perfetto/
+  // common/data_source_descriptor.proto;l=28-60
+  // For Chrome, 'descriptor' will contain the answer received from
+  // 'GetCategories':
+  // https://source.corp.google.com/android/external/perfetto/ui/src/
+  // chrome_extension/chrome_tracing_controller.ts;l=220
+  descriptor: unknown;
+}
+
+// Common fields for all types of targetInfo: Chrome, Android, Linux etc.
+interface TargetInfoBase {
+  name: string;
+
+  // The dataSources exposed by a target. They are fetched from the target
+  // (ex: using QSS for Android or GetCategories for Chrome).
+  dataSources: DataSource[];
+}
+
+export interface AndroidTargetInfo extends TargetInfoBase {
+  targetType: 'ANDROID';
+
   // This is the Android API level. For instance, it can be 32, 31, 30 etc.
   // It is the "API level" column here:
   // https://source.android.com/setup/start/build-numbers
-  androidApiLevel: number;
+  androidApiLevel?: number;
 }
 
-export interface AndroidTargetInfo {
-  name: string;
-  targetType: 'ANDROID';
-  // dynamicTargetInfo is only available after we have been able to connect to
-  // a target. On Android connected via WebUSB, that happens only after the user
-  // has authorized ADB, which can take several seconds.
-  dynamicTargetInfo?: DynamicTargetInfo;
-}
-
-export interface OtherTargetInfo {
-  name: string;
+export interface OtherTargetInfo extends TargetInfoBase {
   targetType: 'CHROME'|'CHROME_OS'|'LINUX';
 }
 
diff --git a/ui/src/common/recordingV2/targets/android_websocket_target.ts b/ui/src/common/recordingV2/targets/android_websocket_target.ts
index 06d94df..8799269 100644
--- a/ui/src/common/recordingV2/targets/android_websocket_target.ts
+++ b/ui/src/common/recordingV2/targets/android_websocket_target.ts
@@ -34,7 +34,8 @@
       targetType: 'ANDROID',
       // TODO(octaviant): fetch the OS from the adb connection
       // once aosp/2127460 is in
-      dynamicTargetInfo: undefined,
+      androidApiLevel: undefined,
+      dataSources: [],
       name: this.serialNumber + ' WebSocket',
     };
   }
diff --git a/ui/src/common/recordingV2/targets/android_webusb_target.ts b/ui/src/common/recordingV2/targets/android_webusb_target.ts
index e0c2c3b..9f02f4f 100644
--- a/ui/src/common/recordingV2/targets/android_webusb_target.ts
+++ b/ui/src/common/recordingV2/targets/android_webusb_target.ts
@@ -16,7 +16,6 @@
 import {AdbConnectionOverWebusb} from '../adb_connection_over_webusb';
 import {AdbKeyManager} from '../auth/adb_key_manager';
 import {
-  DynamicTargetInfo,
   RecordingTargetV2,
   TargetInfo,
   TracingSession,
@@ -29,7 +28,7 @@
 
 export class AndroidWebusbTarget implements RecordingTargetV2 {
   private adbConnection: AdbConnectionOverWebusb;
-  private dynamicTargetInfo?: DynamicTargetInfo;
+  private androidApiLevel?: number;
 
   constructor(
       private factory: AndroidWebusbTargetFactory, private device: USBDevice,
@@ -43,7 +42,8 @@
     return {
       targetType: 'ANDROID',
       // The method 'fetchInfo' will populate this after ADB authorization.
-      dynamicTargetInfo: this.dynamicTargetInfo,
+      androidApiLevel: this.androidApiLevel,
+      dataSources: [],
       name,
     };
   }
@@ -62,10 +62,10 @@
     const adbStream =
         await this.adbConnection.connectSocket('/dev/socket/traced_consumer');
 
-    if (!this.dynamicTargetInfo) {
+    if (!this.androidApiLevel) {
       const version = await this.adbConnection.shellAndGetOutput(
           'getprop ro.build.version.sdk');
-      this.dynamicTargetInfo = {androidApiLevel: Number(version)};
+      this.androidApiLevel = Number(version);
       if (this.factory.onTargetChange) {
         this.factory.onTargetChange();
       }
diff --git a/ui/src/controller/record_controller.ts b/ui/src/controller/record_controller.ts
index 4212122..6bc32e4 100644
--- a/ui/src/controller/record_controller.ts
+++ b/ui/src/controller/record_controller.ts
@@ -99,11 +99,12 @@
   if (targetType == 'ANDROID') {
     targetInfo = {
       targetType,
-      dynamicTargetInfo: {androidApiLevel},
+      androidApiLevel,
+      dataSources: [],
       name: '',
     };
   } else {
-    targetInfo = {targetType, name: ''};
+    targetInfo = {targetType, dataSources: [], name: ''};
   }
 
   return genTraceConfig(uiCfg, targetInfo);
diff --git a/ui/src/controller/trace_controller.ts b/ui/src/controller/trace_controller.ts
index 6a0f049..923fdb5 100644
--- a/ui/src/controller/trace_controller.ts
+++ b/ui/src/controller/trace_controller.ts
@@ -104,7 +104,6 @@
   'android_surfaceflinger',
   'android_batt',
   'android_sysui_cuj',
-  'android_jank',
   'android_camera',
   'android_other_traces',
   'chrome_dropped_frames',
diff --git a/ui/src/frontend/record_page.ts b/ui/src/frontend/record_page.ts
index cef2b2a..10e747a 100644
--- a/ui/src/frontend/record_page.ts
+++ b/ui/src/frontend/record_page.ts
@@ -13,15 +13,12 @@
 // limitations under the License.
 
 
-import {produce} from 'immer';
 import * as m from 'mithril';
 
 import {Actions} from '../common/actions';
 import {featureFlags} from '../common/feature_flags';
-import {MeminfoCounters, VmstatCounters} from '../common/protos';
 import {
   AdbRecordingTarget,
-  getBuiltinChromeCategoryList,
   getDefaultRecordingTargets,
   hasActiveProbes,
   isAdbTarget,
@@ -33,7 +30,6 @@
   LoadedConfig,
   MAX_TIME,
   RecordingTarget,
-  RecordMode,
 } from '../common/state';
 import {AdbOverWebUsb} from '../controller/adb';
 import {
@@ -49,20 +45,17 @@
   recordTargetStore,
 } from './record_config';
 import {
-  CategoriesCheckboxList,
   CodeSnippet,
-  CompactProbe,
-  Dropdown,
-  DropdownAttrs,
-  Probe,
-  ProbeAttrs,
-  Slider,
-  SliderAttrs,
-  Textarea,
-  TextareaAttrs,
-  Toggle,
-  ToggleAttrs,
 } from './record_widgets';
+import {AdvancedSettings} from './recording/advanced_settings';
+import {AndroidSettings} from './recording/android_settings';
+import {ChromeSettings} from './recording/chrome_settings';
+import {CpuSettings} from './recording/cpu_settings';
+import {GpuSettings} from './recording/gpu_settings';
+import {MemorySettings} from './recording/memory_settings';
+import {PowerSettings} from './recording/power_settings';
+import {RecordingSectionAttrs} from './recording/recording_sections';
+import {RecordingSettings} from './recording/recording_settings';
 
 export const PERSIST_CONFIG_FLAG = featureFlags.register({
   id: 'persistConfigsUI',
@@ -71,746 +64,18 @@
   defaultValue: true,
 });
 
-export const POLL_INTERVAL_MS = [250, 500, 1000, 2500, 5000, 30000, 60000];
-
-export const ATRACE_CATEGORIES = new Map<string, string>();
-ATRACE_CATEGORIES.set('adb', 'ADB');
-ATRACE_CATEGORIES.set('aidl', 'AIDL calls');
-ATRACE_CATEGORIES.set('am', 'Activity Manager');
-ATRACE_CATEGORIES.set('audio', 'Audio');
-ATRACE_CATEGORIES.set('binder_driver', 'Binder Kernel driver');
-ATRACE_CATEGORIES.set('binder_lock', 'Binder global lock trace');
-ATRACE_CATEGORIES.set('bionic', 'Bionic C library');
-ATRACE_CATEGORIES.set('camera', 'Camera');
-ATRACE_CATEGORIES.set('dalvik', 'ART & Dalvik');
-ATRACE_CATEGORIES.set('database', 'Database');
-ATRACE_CATEGORIES.set('gfx', 'Graphics');
-ATRACE_CATEGORIES.set('hal', 'Hardware Modules');
-ATRACE_CATEGORIES.set('input', 'Input');
-ATRACE_CATEGORIES.set('network', 'Network');
-ATRACE_CATEGORIES.set('nnapi', 'Neural Network API');
-ATRACE_CATEGORIES.set('pm', 'Package Manager');
-ATRACE_CATEGORIES.set('power', 'Power Management');
-ATRACE_CATEGORIES.set('res', 'Resource Loading');
-ATRACE_CATEGORIES.set('rro', 'Resource Overlay');
-ATRACE_CATEGORIES.set('rs', 'RenderScript');
-ATRACE_CATEGORIES.set('sm', 'Sync Manager');
-ATRACE_CATEGORIES.set('ss', 'System Server');
-ATRACE_CATEGORIES.set('vibrator', 'Vibrator');
-ATRACE_CATEGORIES.set('video', 'Video');
-ATRACE_CATEGORIES.set('view', 'View System');
-ATRACE_CATEGORIES.set('webview', 'WebView');
-ATRACE_CATEGORIES.set('wm', 'Window Manager');
-
-export const LOG_BUFFERS = new Map<string, string>();
-LOG_BUFFERS.set('LID_CRASH', 'Crash');
-LOG_BUFFERS.set('LID_DEFAULT', 'Main');
-LOG_BUFFERS.set('LID_EVENTS', 'Binary events');
-LOG_BUFFERS.set('LID_KERNEL', 'Kernel');
-LOG_BUFFERS.set('LID_RADIO', 'Radio');
-LOG_BUFFERS.set('LID_SECURITY', 'Security');
-LOG_BUFFERS.set('LID_STATS', 'Stats');
-LOG_BUFFERS.set('LID_SYSTEM', 'System');
-
-export const FTRACE_CATEGORIES = new Map<string, string>();
-FTRACE_CATEGORIES.set('binder/*', 'binder');
-FTRACE_CATEGORIES.set('block/*', 'block');
-FTRACE_CATEGORIES.set('clk/*', 'clk');
-FTRACE_CATEGORIES.set('ext4/*', 'ext4');
-FTRACE_CATEGORIES.set('f2fs/*', 'f2fs');
-FTRACE_CATEGORIES.set('i2c/*', 'i2c');
-FTRACE_CATEGORIES.set('irq/*', 'irq');
-FTRACE_CATEGORIES.set('kmem/*', 'kmem');
-FTRACE_CATEGORIES.set('memory_bus/*', 'memory_bus');
-FTRACE_CATEGORIES.set('mmc/*', 'mmc');
-FTRACE_CATEGORIES.set('oom/*', 'oom');
-FTRACE_CATEGORIES.set('power/*', 'power');
-FTRACE_CATEGORIES.set('regulator/*', 'regulator');
-FTRACE_CATEGORIES.set('sched/*', 'sched');
-FTRACE_CATEGORIES.set('sync/*', 'sync');
-FTRACE_CATEGORIES.set('task/*', 'task');
-FTRACE_CATEGORIES.set('task/*', 'task');
-FTRACE_CATEGORIES.set('vmscan/*', 'vmscan');
-FTRACE_CATEGORIES.set('fastrpc/*', 'fastrpc');
-
-export function RecSettings(cssClass: string) {
-  const S = (x: number) => x * 1000;
-  const M = (x: number) => x * 1000 * 60;
-  const H = (x: number) => x * 1000 * 60 * 60;
-
-  const cfg = globals.state.recordConfig;
-
-  const recButton = (mode: RecordMode, title: string, img: string) => {
-    const checkboxArgs = {
-      checked: cfg.mode === mode,
-      onchange: (e: InputEvent) => {
-        const checked = (e.target as HTMLInputElement).checked;
-        if (!checked) return;
-        const traceCfg = produce(globals.state.recordConfig, (draft) => {
-          draft.mode = mode;
-        });
-        globals.dispatch(Actions.setRecordConfig({config: traceCfg}));
-      },
-    };
-    return m(
-        `label${cfg.mode === mode ? '.selected' : ''}`,
-        m(`input[type=radio][name=rec_mode]`, checkboxArgs),
-        m(`img[src=${globals.root}assets/${img}]`),
-        m('span', title));
-  };
-
-  return m(
-      `.record-section${cssClass}`,
-      m('header', 'Recording mode'),
-      m('.record-mode',
-        recButton('STOP_WHEN_FULL', 'Stop when full', 'rec_one_shot.png'),
-        recButton('RING_BUFFER', 'Ring buffer', 'rec_ring_buf.png'),
-        recButton('LONG_TRACE', 'Long trace', 'rec_long_trace.png')),
-
-      m(Slider, {
-        title: 'In-memory buffer size',
-        icon: '360',
-        values: [4, 8, 16, 32, 64, 128, 256, 512],
-        unit: 'MB',
-        set: (cfg, val) => cfg.bufferSizeMb = val,
-        get: (cfg) => cfg.bufferSizeMb,
-      } as SliderAttrs),
-
-      m(Slider, {
-        title: 'Max duration',
-        icon: 'timer',
-        values: [S(10), S(15), S(30), S(60), M(5), M(30), H(1), H(6), H(12)],
-        isTime: true,
-        unit: 'h:m:s',
-        set: (cfg, val) => cfg.durationMs = val,
-        get: (cfg) => cfg.durationMs,
-      } as SliderAttrs),
-      m(Slider, {
-        title: 'Max file size',
-        icon: 'save',
-        cssClass: cfg.mode !== 'LONG_TRACE' ? '.hide' : '',
-        values: [5, 25, 50, 100, 500, 1000, 1000 * 5, 1000 * 10],
-        unit: 'MB',
-        set: (cfg, val) => cfg.maxFileSizeMb = val,
-        get: (cfg) => cfg.maxFileSizeMb,
-      } as SliderAttrs),
-      m(Slider, {
-        title: 'Flush on disk every',
-        cssClass: cfg.mode !== 'LONG_TRACE' ? '.hide' : '',
-        icon: 'av_timer',
-        values: [100, 250, 500, 1000, 2500, 5000],
-        unit: 'ms',
-        set: (cfg, val) => cfg.fileWritePeriodMs = val,
-        get: (cfg) => cfg.fileWritePeriodMs || 0,
-      } as SliderAttrs));
-}
-
-export function PowerSettings(cssClass: string) {
-  const DOC_URL = 'https://perfetto.dev/docs/data-sources/battery-counters';
-  const descr =
-      [m('div',
-         m('span', `Polls charge counters and instantaneous power draw from
-                    the battery power management IC and the power rails from
-                    the PowerStats HAL (`),
-         m('a', {href: DOC_URL, target: '_blank'}, 'see docs for more'),
-         m('span', ')'))];
-  if (globals.isInternalUser) {
-    descr.push(m(
-        'div',
-        m('span', 'Googlers: See '),
-        m('a',
-          {href: 'http://go/power-rails-internal-doc', target: '_blank'},
-          'this doc'),
-        m('span', ` for instructions on how to change the refault rail selection
-                  on internal devices.`),
-        ));
-  }
-  return m(
-      `.record-section${cssClass}`,
-      m(Probe,
-        {
-          title: 'Battery drain & power rails',
-          img: 'rec_battery_counters.png',
-          descr,
-          setEnabled: (cfg, val) => cfg.batteryDrain = val,
-          isEnabled: (cfg) => cfg.batteryDrain,
-        } as ProbeAttrs,
-        m(Slider, {
-          title: 'Poll interval',
-          cssClass: '.thin',
-          values: POLL_INTERVAL_MS,
-          unit: 'ms',
-          set: (cfg, val) => cfg.batteryDrainPollMs = val,
-          get: (cfg) => cfg.batteryDrainPollMs,
-        } as SliderAttrs)),
-      m(Probe, {
-        title: 'Board voltages & frequencies',
-        img: 'rec_board_voltage.png',
-        descr: 'Tracks voltage and frequency changes from board sensors',
-        setEnabled: (cfg, val) => cfg.boardSensors = val,
-        isEnabled: (cfg) => cfg.boardSensors,
-      } as ProbeAttrs));
-}
-
-export function GpuSettings(cssClass: string) {
-  return m(
-      `.record-section${cssClass}`,
-      m(Probe, {
-        title: 'GPU frequency',
-        img: 'rec_cpu_freq.png',
-        descr: 'Records gpu frequency via ftrace',
-        setEnabled: (cfg, val) => cfg.gpuFreq = val,
-        isEnabled: (cfg) => cfg.gpuFreq,
-      } as ProbeAttrs),
-      m(Probe, {
-        title: 'GPU memory',
-        img: 'rec_gpu_mem_total.png',
-        descr: `Allows to track per process and global total GPU memory usages.
-                (Available on recent Android 12+ kernels)`,
-        setEnabled: (cfg, val) => cfg.gpuMemTotal = val,
-        isEnabled: (cfg) => cfg.gpuMemTotal,
-      } as ProbeAttrs));
-}
-
-export function CpuSettings(cssClass: string) {
-  return m(
-      `.record-section${cssClass}`,
-      m(Probe,
-        {
-          title: 'Coarse CPU usage counter',
-          img: 'rec_cpu_coarse.png',
-          descr: `Lightweight polling of CPU usage counters via /proc/stat.
-                    Allows to periodically monitor CPU usage.`,
-          setEnabled: (cfg, val) => cfg.cpuCoarse = val,
-          isEnabled: (cfg) => cfg.cpuCoarse,
-        } as ProbeAttrs,
-        m(Slider, {
-          title: 'Poll interval',
-          cssClass: '.thin',
-          values: POLL_INTERVAL_MS,
-          unit: 'ms',
-          set: (cfg, val) => cfg.cpuCoarsePollMs = val,
-          get: (cfg) => cfg.cpuCoarsePollMs,
-        } as SliderAttrs)),
-      m(Probe, {
-        title: 'Scheduling details',
-        img: 'rec_cpu_fine.png',
-        descr: 'Enables high-detailed tracking of scheduling events',
-        setEnabled: (cfg, val) => cfg.cpuSched = val,
-        isEnabled: (cfg) => cfg.cpuSched,
-      } as ProbeAttrs),
-      m(Probe, {
-        title: 'CPU frequency and idle states',
-        img: 'rec_cpu_freq.png',
-        descr: 'Records cpu frequency and idle state changes via ftrace',
-        setEnabled: (cfg, val) => cfg.cpuFreq = val,
-        isEnabled: (cfg) => cfg.cpuFreq,
-      } as ProbeAttrs),
-      m(Probe, {
-        title: 'Syscalls',
-        img: 'rec_syscalls.png',
-        descr: `Tracks the enter and exit of all syscalls. On Android
-                requires a userdebug or eng build.`,
-        setEnabled: (cfg, val) => cfg.cpuSyscall = val,
-        isEnabled: (cfg) => cfg.cpuSyscall,
-      } as ProbeAttrs));
-}
-
-export function HeapSettings(cssClass: string) {
-  const valuesForMS = [
-    0,
-    1000,
-    10 * 1000,
-    30 * 1000,
-    60 * 1000,
-    5 * 60 * 1000,
-    10 * 60 * 1000,
-    30 * 60 * 1000,
-    60 * 60 * 1000,
-  ];
-  const valuesForShMemBuff = [
-    0,
-    512,
-    1024,
-    2 * 1024,
-    4 * 1024,
-    8 * 1024,
-    16 * 1024,
-    32 * 1024,
-    64 * 1024,
-    128 * 1024,
-    256 * 1024,
-    512 * 1024,
-    1024 * 1024,
-    64 * 1024 * 1024,
-    128 * 1024 * 1024,
-    256 * 1024 * 1024,
-    512 * 1024 * 1024,
-  ];
-
-  return m(
-      `.${cssClass}`,
-      m(Textarea, {
-        title: 'Names or pids of the processes to track',
-        docsLink:
-            'https://perfetto.dev/docs/data-sources/native-heap-profiler#heapprofd-targets',
-        placeholder: 'One per line, e.g.:\n' +
-            'system_server\n' +
-            'com.google.android.apps.photos\n' +
-            '1503',
-        set: (cfg, val) => cfg.hpProcesses = val,
-        get: (cfg) => cfg.hpProcesses,
-      } as TextareaAttrs),
-      m(Slider, {
-        title: 'Sampling interval',
-        cssClass: '.thin',
-        values: [
-          /* eslint-disable no-multi-spaces */
-          0,     1,     2,      4,      8,      16,      32,   64,
-          128,   256,   512,    1024,   2048,   4096,    8192, 16384,
-          32768, 65536, 131072, 262144, 524288, 1048576,
-          /* eslint-enable no-multi-spaces */
-        ],
-        unit: 'B',
-        min: 0,
-        set: (cfg, val) => cfg.hpSamplingIntervalBytes = val,
-        get: (cfg) => cfg.hpSamplingIntervalBytes,
-      } as SliderAttrs),
-      m(Slider, {
-        title: 'Continuous dumps interval ',
-        description: 'Time between following dumps (0 = disabled)',
-        cssClass: '.thin',
-        values: valuesForMS,
-        unit: 'ms',
-        min: 0,
-        set: (cfg, val) => {
-          cfg.hpContinuousDumpsInterval = val;
-        },
-        get: (cfg) => cfg.hpContinuousDumpsInterval,
-      } as SliderAttrs),
-      m(Slider, {
-        title: 'Continuous dumps phase',
-        description: 'Time before first dump',
-        cssClass: `.thin${
-            globals.state.recordConfig.hpContinuousDumpsInterval === 0 ?
-                '.greyed-out' :
-                ''}`,
-        values: valuesForMS,
-        unit: 'ms',
-        min: 0,
-        disabled: globals.state.recordConfig.hpContinuousDumpsInterval === 0,
-        set: (cfg, val) => cfg.hpContinuousDumpsPhase = val,
-        get: (cfg) => cfg.hpContinuousDumpsPhase,
-      } as SliderAttrs),
-      m(Slider, {
-        title: `Shared memory buffer`,
-        cssClass: '.thin',
-        values: valuesForShMemBuff.filter(
-            (value) => value === 0 || value >= 8192 && value % 4096 === 0),
-        unit: 'B',
-        min: 0,
-        set: (cfg, val) => cfg.hpSharedMemoryBuffer = val,
-        get: (cfg) => cfg.hpSharedMemoryBuffer,
-      } as SliderAttrs),
-      m(Toggle, {
-        title: 'Block client',
-        cssClass: '.thin',
-        descr: `Slow down target application if profiler cannot keep up.`,
-        setEnabled: (cfg, val) => cfg.hpBlockClient = val,
-        isEnabled: (cfg) => cfg.hpBlockClient,
-      } as ToggleAttrs),
-      m(Toggle, {
-        title: 'All custom allocators (Q+)',
-        cssClass: '.thin',
-        descr: `If the target application exposes custom allocators, also
-sample from those.`,
-        setEnabled: (cfg, val) => cfg.hpAllHeaps = val,
-        isEnabled: (cfg) => cfg.hpAllHeaps,
-      } as ToggleAttrs),
-      // TODO(hjd): Add advanced options.
-  );
-}
-
-export function JavaHeapDumpSettings(cssClass: string) {
-  const valuesForMS = [
-    0,
-    1000,
-    10 * 1000,
-    30 * 1000,
-    60 * 1000,
-    5 * 60 * 1000,
-    10 * 60 * 1000,
-    30 * 60 * 1000,
-    60 * 60 * 1000,
-  ];
-
-  return m(
-      `.${cssClass}`,
-      m(Textarea, {
-        title: 'Names or pids of the processes to track',
-        placeholder: 'One per line, e.g.:\n' +
-            'com.android.vending\n' +
-            '1503',
-        set: (cfg, val) => cfg.jpProcesses = val,
-        get: (cfg) => cfg.jpProcesses,
-      } as TextareaAttrs),
-      m(Slider, {
-        title: 'Continuous dumps interval ',
-        description: 'Time between following dumps (0 = disabled)',
-        cssClass: '.thin',
-        values: valuesForMS,
-        unit: 'ms',
-        min: 0,
-        set: (cfg, val) => {
-          cfg.jpContinuousDumpsInterval = val;
-        },
-        get: (cfg) => cfg.jpContinuousDumpsInterval,
-      } as SliderAttrs),
-      m(Slider, {
-        title: 'Continuous dumps phase',
-        description: 'Time before first dump',
-        cssClass: `.thin${
-            globals.state.recordConfig.jpContinuousDumpsInterval === 0 ?
-                '.greyed-out' :
-                ''}`,
-        values: valuesForMS,
-        unit: 'ms',
-        min: 0,
-        disabled: globals.state.recordConfig.jpContinuousDumpsInterval === 0,
-        set: (cfg, val) => cfg.jpContinuousDumpsPhase = val,
-        get: (cfg) => cfg.jpContinuousDumpsPhase,
-      } as SliderAttrs),
-  );
-}
-
-export function MemorySettings(cssClass: string) {
-  const meminfoOpts = new Map<string, string>();
-  for (const x in MeminfoCounters) {
-    if (typeof MeminfoCounters[x] === 'number' &&
-        !`${x}`.endsWith('_UNSPECIFIED')) {
-      meminfoOpts.set(x, x.replace('MEMINFO_', '').toLowerCase());
-    }
-  }
-  const vmstatOpts = new Map<string, string>();
-  for (const x in VmstatCounters) {
-    if (typeof VmstatCounters[x] === 'number' &&
-        !`${x}`.endsWith('_UNSPECIFIED')) {
-      vmstatOpts.set(x, x.replace('VMSTAT_', '').toLowerCase());
-    }
-  }
-  return m(
-      `.record-section${cssClass}`,
-      m(Probe,
-        {
-          title: 'Native heap profiling',
-          img: 'rec_native_heap_profiler.png',
-          descr: `Track native heap allocations & deallocations of an Android
-               process. (Available on Android 10+)`,
-          setEnabled: (cfg, val) => cfg.heapProfiling = val,
-          isEnabled: (cfg) => cfg.heapProfiling,
-        } as ProbeAttrs,
-        HeapSettings(cssClass)),
-      m(Probe,
-        {
-          title: 'Java heap dumps',
-          img: 'rec_java_heap_dump.png',
-          descr: `Dump information about the Java object graph of an
-          Android app. (Available on Android 11+)`,
-          setEnabled: (cfg, val) => cfg.javaHeapDump = val,
-          isEnabled: (cfg) => cfg.javaHeapDump,
-        } as ProbeAttrs,
-        JavaHeapDumpSettings(cssClass)),
-      m(Probe,
-        {
-          title: 'Kernel meminfo',
-          img: 'rec_meminfo.png',
-          descr: 'Polling of /proc/meminfo',
-          setEnabled: (cfg, val) => cfg.meminfo = val,
-          isEnabled: (cfg) => cfg.meminfo,
-        } as ProbeAttrs,
-        m(Slider, {
-          title: 'Poll interval',
-          cssClass: '.thin',
-          values: POLL_INTERVAL_MS,
-          unit: 'ms',
-          set: (cfg, val) => cfg.meminfoPeriodMs = val,
-          get: (cfg) => cfg.meminfoPeriodMs,
-        } as SliderAttrs),
-        m(Dropdown, {
-          title: 'Select counters',
-          cssClass: '.multicolumn',
-          options: meminfoOpts,
-          set: (cfg, val) => cfg.meminfoCounters = val,
-          get: (cfg) => cfg.meminfoCounters,
-        } as DropdownAttrs)),
-      m(Probe, {
-        title: 'High-frequency memory events',
-        img: 'rec_mem_hifreq.png',
-        descr: `Allows to track short memory spikes and transitories through
-                ftrace's mm_event, rss_stat and ion events. Available only
-                on recent Android Q+ kernels`,
-        setEnabled: (cfg, val) => cfg.memHiFreq = val,
-        isEnabled: (cfg) => cfg.memHiFreq,
-      } as ProbeAttrs),
-      m(Probe, {
-        title: 'Low memory killer',
-        img: 'rec_lmk.png',
-        descr: `Record LMK events. Works both with the old in-kernel LMK
-                and the newer userspace lmkd. It also tracks OOM score
-                adjustments.`,
-        setEnabled: (cfg, val) => cfg.memLmk = val,
-        isEnabled: (cfg) => cfg.memLmk,
-      } as ProbeAttrs),
-      m(Probe,
-        {
-          title: 'Per process stats',
-          img: 'rec_ps_stats.png',
-          descr: `Periodically samples all processes in the system tracking:
-                    their thread list, memory counters (RSS, swap and other
-                    /proc/status counters) and oom_score_adj.`,
-          setEnabled: (cfg, val) => cfg.procStats = val,
-          isEnabled: (cfg) => cfg.procStats,
-        } as ProbeAttrs,
-        m(Slider, {
-          title: 'Poll interval',
-          cssClass: '.thin',
-          values: POLL_INTERVAL_MS,
-          unit: 'ms',
-          set: (cfg, val) => cfg.procStatsPeriodMs = val,
-          get: (cfg) => cfg.procStatsPeriodMs,
-        } as SliderAttrs)),
-      m(Probe,
-        {
-          title: 'Virtual memory stats',
-          img: 'rec_vmstat.png',
-          descr: `Periodically polls virtual memory stats from /proc/vmstat.
-                    Allows to gather statistics about swap, eviction,
-                    compression and pagecache efficiency`,
-          setEnabled: (cfg, val) => cfg.vmstat = val,
-          isEnabled: (cfg) => cfg.vmstat,
-        } as ProbeAttrs,
-        m(Slider, {
-          title: 'Poll interval',
-          cssClass: '.thin',
-          values: POLL_INTERVAL_MS,
-          unit: 'ms',
-          set: (cfg, val) => cfg.vmstatPeriodMs = val,
-          get: (cfg) => cfg.vmstatPeriodMs,
-        } as SliderAttrs),
-        m(Dropdown, {
-          title: 'Select counters',
-          cssClass: '.multicolumn',
-          options: vmstatOpts,
-          set: (cfg, val) => cfg.vmstatCounters = val,
-          get: (cfg) => cfg.vmstatCounters,
-        } as DropdownAttrs)));
-}
-
-function AtraceAppsList() {
-  if (globals.state.recordConfig.allAtraceApps) {
-    return m('div');
-  }
-
-  return m(Textarea, {
-    placeholder: 'Apps to profile, one per line, e.g.:\n' +
-        'com.android.phone\n' +
-        'lmkd\n' +
-        'com.android.nfc',
-    cssClass: '.atrace-apps-list',
-    set: (cfg, val) => cfg.atraceApps = val,
-    get: (cfg) => cfg.atraceApps,
-  } as TextareaAttrs);
-}
-
-export function AndroidSettings(cssClass: string) {
-  return m(
-      `.record-section${cssClass}`,
-      m(Probe,
-        {
-          title: 'Atrace userspace annotations',
-          img: 'rec_atrace.png',
-          descr: `Enables C++ / Java codebase annotations (ATRACE_BEGIN() /
-                    os.Trace())`,
-          setEnabled: (cfg, val) => cfg.atrace = val,
-          isEnabled: (cfg) => cfg.atrace,
-        } as ProbeAttrs,
-        m(Dropdown, {
-          title: 'Categories',
-          cssClass: '.multicolumn.atrace-categories',
-          options: ATRACE_CATEGORIES,
-          set: (cfg, val) => cfg.atraceCats = val,
-          get: (cfg) => cfg.atraceCats,
-        } as DropdownAttrs),
-        m(Toggle, {
-          title: 'Record events from all Android apps and services',
-          descr: '',
-          setEnabled: (cfg, val) => cfg.allAtraceApps = val,
-          isEnabled: (cfg) => cfg.allAtraceApps,
-        } as ToggleAttrs),
-        AtraceAppsList()),
-      m(Probe,
-        {
-          title: 'Event log (logcat)',
-          img: 'rec_logcat.png',
-          descr: `Streams the event log into the trace. If no buffer filter is
-                    specified, all buffers are selected.`,
-          setEnabled: (cfg, val) => cfg.androidLogs = val,
-          isEnabled: (cfg) => cfg.androidLogs,
-        } as ProbeAttrs,
-        m(Dropdown, {
-          title: 'Buffers',
-          cssClass: '.multicolumn',
-          options: LOG_BUFFERS,
-          set: (cfg, val) => cfg.androidLogBuffers = val,
-          get: (cfg) => cfg.androidLogBuffers,
-        } as DropdownAttrs)),
-      m(Probe, {
-        title: 'Frame timeline',
-        img: 'rec_frame_timeline.png',
-        descr: `Records expected/actual frame timings from surface_flinger.
-                    Requires Android 12 (S) or above.`,
-        setEnabled: (cfg, val) => cfg.androidFrameTimeline = val,
-        isEnabled: (cfg) => cfg.androidFrameTimeline,
-      } as ProbeAttrs));
-}
-
-
-export function ChromeSettings(cssClass: string) {
-  return m(
-      `.record-section${cssClass}`,
-      CompactProbe({
-        title: 'Task scheduling',
-        setEnabled: (cfg, val) => cfg.taskScheduling = val,
-        isEnabled: (cfg) => cfg.taskScheduling,
-      }),
-      CompactProbe({
-        title: 'IPC flows',
-        setEnabled: (cfg, val) => cfg.ipcFlows = val,
-        isEnabled: (cfg) => cfg.ipcFlows,
-      }),
-      CompactProbe({
-        title: 'Javascript execution',
-        setEnabled: (cfg, val) => cfg.jsExecution = val,
-        isEnabled: (cfg) => cfg.jsExecution,
-      }),
-      CompactProbe({
-        title: 'Web content rendering, layout and compositing',
-        setEnabled: (cfg, val) => cfg.webContentRendering = val,
-        isEnabled: (cfg) => cfg.webContentRendering,
-      }),
-      CompactProbe({
-        title: 'UI rendering & surface compositing',
-        setEnabled: (cfg, val) => cfg.uiRendering = val,
-        isEnabled: (cfg) => cfg.uiRendering,
-      }),
-      CompactProbe({
-        title: 'Input events',
-        setEnabled: (cfg, val) => cfg.inputEvents = val,
-        isEnabled: (cfg) => cfg.inputEvents,
-      }),
-      CompactProbe({
-        title: 'Navigation & Loading',
-        setEnabled: (cfg, val) => cfg.navigationAndLoading = val,
-        isEnabled: (cfg) => cfg.navigationAndLoading,
-      }),
-      CompactProbe({
-        title: 'Chrome Logs',
-        setEnabled: (cfg, val) => cfg.chromeLogs = val,
-        isEnabled: (cfg) => cfg.chromeLogs,
-      }),
-      ChromeCategoriesSelection());
-}
-
-function ChromeCategoriesSelection() {
-  // If we are attempting to record via the Chrome extension, we receive the
-  // list of actually supported categories via DevTools. Otherwise, we fall back
-  // to an integrated list of categories from a recent version of Chrome.
-  let categories = globals.state.chromeCategories;
-  if (!categories || !isChromeTarget(globals.state.recordingTarget)) {
-    categories = getBuiltinChromeCategoryList();
-  }
-
-  const defaultCategories = new Map<string, string>();
-  const disabledByDefaultCategories = new Map<string, string>();
-  const disabledPrefix = 'disabled-by-default-';
-  categories.forEach((cat) => {
-    if (cat.startsWith(disabledPrefix)) {
-      disabledByDefaultCategories.set(cat, cat.replace(disabledPrefix, ''));
-    } else {
-      defaultCategories.set(cat, cat);
-    }
-  });
-
-  return m(
-      '.chrome-categories',
-      m(CategoriesCheckboxList, {
-        categories: defaultCategories,
-        title: 'Additional categories',
-        get: (cfg) => cfg.chromeCategoriesSelected,
-        set: (cfg, val) => cfg.chromeCategoriesSelected = val,
-      }),
-      m(CategoriesCheckboxList, {
-        categories: disabledByDefaultCategories,
-        title: 'High overhead categories',
-        get: (cfg) => cfg.chromeHighOverheadCategoriesSelected,
-        set: (cfg, val) => cfg.chromeHighOverheadCategoriesSelected = val,
-      }));
-}
-
-export function AdvancedSettings(cssClass: string) {
-  return m(
-      `.record-section${cssClass}`,
-      m(Probe,
-        {
-          title: 'Advanced ftrace config',
-          img: 'rec_ftrace.png',
-          descr: `Enable individual events and tune the kernel-tracing (ftrace)
-                  module. The events enabled here are in addition to those from
-                  enabled by other probes.`,
-          setEnabled: (cfg, val) => cfg.ftrace = val,
-          isEnabled: (cfg) => cfg.ftrace,
-        } as ProbeAttrs,
-        m(Toggle, {
-          title: 'Resolve kernel symbols',
-          cssClass: '.thin',
-          descr: `Enables lookup via /proc/kallsyms for workqueue,
-              sched_blocked_reason and other events (userdebug/eng builds only).`,
-          setEnabled: (cfg, val) => cfg.symbolizeKsyms = val,
-          isEnabled: (cfg) => cfg.symbolizeKsyms,
-        } as ToggleAttrs),
-        m(Slider, {
-          title: 'Buf size',
-          cssClass: '.thin',
-          values: [0, 512, 1024, 2 * 1024, 4 * 1024, 16 * 1024, 32 * 1024],
-          unit: 'KB',
-          zeroIsDefault: true,
-          set: (cfg, val) => cfg.ftraceBufferSizeKb = val,
-          get: (cfg) => cfg.ftraceBufferSizeKb,
-        } as SliderAttrs),
-        m(Slider, {
-          title: 'Drain rate',
-          cssClass: '.thin',
-          values: [0, 100, 250, 500, 1000, 2500, 5000],
-          unit: 'ms',
-          zeroIsDefault: true,
-          set: (cfg, val) => cfg.ftraceDrainPeriodMs = val,
-          get: (cfg) => cfg.ftraceDrainPeriodMs,
-        } as SliderAttrs),
-        m(Dropdown, {
-          title: 'Event groups',
-          cssClass: '.multicolumn.ftrace-events',
-          options: FTRACE_CATEGORIES,
-          set: (cfg, val) => cfg.ftraceEvents = val,
-          get: (cfg) => cfg.ftraceEvents,
-        } as DropdownAttrs),
-        m(Textarea, {
-          placeholder: 'Add extra events, one per line, e.g.:\n' +
-              'sched/sched_switch\n' +
-              'kmem/*',
-          set: (cfg, val) => cfg.ftraceExtraEvents = val,
-          get: (cfg) => cfg.ftraceExtraEvents,
-        } as TextareaAttrs)));
-}
+export const RECORDING_SECTIONS = [
+  'buffers',
+  'instructions',
+  'config',
+  'cpu',
+  'gpu',
+  'power',
+  'memory',
+  'android',
+  'chrome',
+  'advanced',
+];
 
 function RecordHeader() {
   return m(
@@ -1464,31 +729,41 @@
       m('ul', probes));
 }
 
+export function maybeGetActiveCss(routePage: string, section: string): string {
+  return routePage === section ? '.active' : '';
+}
 
 export const RecordPage = createPage({
   view({attrs}: m.Vnode<PageAttrs>) {
-    const SECTIONS: {[property: string]: (cssClass: string) => m.Child} = {
-      buffers: RecSettings,
-      instructions: Instructions,
-      config: Configurations,
-      cpu: CpuSettings,
-      gpu: GpuSettings,
-      power: PowerSettings,
-      memory: MemorySettings,
-      android: AndroidSettings,
-      chrome: ChromeSettings,
-      advanced: AdvancedSettings,
-    };
-
     const pages: m.Children = [];
     // we need to remove the `/` character from the route
     let routePage = attrs.subpage ? attrs.subpage.substr(1) : '';
-    if (!Object.keys(SECTIONS).includes(routePage)) {
+    if (!RECORDING_SECTIONS.includes(routePage)) {
       routePage = 'buffers';
     }
-    for (const key of Object.keys(SECTIONS)) {
-      const cssClass = routePage === key ? '.active' : '';
-      pages.push(SECTIONS[key](cssClass));
+    pages.push(recordMenu(routePage));
+
+    pages.push(m(RecordingSettings, {
+      dataSources: [],
+      cssClass: maybeGetActiveCss(routePage, 'buffers'),
+    } as RecordingSectionAttrs));
+    pages.push(Instructions(maybeGetActiveCss(routePage, 'instructions')));
+    pages.push(Configurations(maybeGetActiveCss(routePage, 'config')));
+
+    const settingsSections = new Map([
+      ['cpu', CpuSettings],
+      ['gpu', GpuSettings],
+      ['power', PowerSettings],
+      ['memory', MemorySettings],
+      ['android', AndroidSettings],
+      ['chrome', ChromeSettings],
+      ['advanced', AdvancedSettings],
+    ]);
+    for (const [section, component] of settingsSections.entries()) {
+      pages.push(m(component, {
+        dataSources: [],
+        cssClass: maybeGetActiveCss(routePage, section),
+      } as RecordingSectionAttrs));
     }
 
     return m(
diff --git a/ui/src/frontend/record_page_v2.ts b/ui/src/frontend/record_page_v2.ts
index 3a02053..f9fde2d 100644
--- a/ui/src/frontend/record_page_v2.ts
+++ b/ui/src/frontend/record_page_v2.ts
@@ -56,17 +56,20 @@
 import {publishBufferUsage} from './publish';
 import {autosaveConfigStore, recordConfigStore} from './record_config';
 import {
-  AdvancedSettings,
-  AndroidSettings,
   Configurations,
-  CpuSettings,
-  GpuSettings,
-  MemorySettings,
+  maybeGetActiveCss,
   PERSIST_CONFIG_FLAG,
-  PowerSettings,
-  RecSettings,
+  RECORDING_SECTIONS,
 } from './record_page';
 import {CodeSnippet} from './record_widgets';
+import {AdvancedSettings} from './recording/advanced_settings';
+import {AndroidSettings} from './recording/android_settings';
+import {CpuSettings} from './recording/cpu_settings';
+import {GpuSettings} from './recording/gpu_settings';
+import {MemorySettings} from './recording/memory_settings';
+import {PowerSettings} from './recording/power_settings';
+import {RecordingSectionAttrs} from './recording/recording_sections';
+import {RecordingSettings} from './recording/recording_settings';
 
 // Wraps a tracing session promise while the promise is being resolved (e.g.
 // while we are awaiting for ADB auth).
@@ -366,8 +369,8 @@
         notes.push(msgLinux);
         break;
       case 'ANDROID': {
-        const androidApiLevel = targetInfo.dynamicTargetInfo?.androidApiLevel;
-        if (androidApiLevel && androidApiLevel == 28) {
+        const androidApiLevel = targetInfo.androidApiLevel;
+        if (androidApiLevel === 28) {
           notes.push(m('.note', msgFeatNotSupported, msgSideload));
         } else if (androidApiLevel && androidApiLevel <= 27) {
           notes.push(m('.note', msgPerfettoNotSupported, msgSideload));
@@ -406,8 +409,7 @@
   const pbtx = data ? data.configProtoText : '';
   let cmd = '';
   if (targetInfo.targetType === 'ANDROID' &&
-      targetInfo.dynamicTargetInfo?.androidApiLevel &&
-      targetInfo.dynamicTargetInfo.androidApiLevel === 28) {
+      targetInfo.androidApiLevel === 28) {
     cmd += `echo '${pbBase64}' | \n`;
     cmd += 'base64 --decode | \n';
     cmd += 'adb shell "perfetto -c - -o /data/misc/perfetto-traces/trace"\n';
@@ -441,7 +443,11 @@
   }
 
   const targetInfo = recordingTargetV2.getInfo();
-  if (targetInfo.targetType === 'ANDROID' && !targetInfo.dynamicTargetInfo) {
+  // The absence of androidApiLevel shows that we have not connected to the
+  // device, therefore we can not start recording.
+  // TODO(octaviant): encapsulation should be stricter here, look into making
+  // this a method
+  if (targetInfo.targetType === 'ANDROID' && !targetInfo.androidApiLevel) {
     return undefined;
   }
 
@@ -648,35 +654,45 @@
   }
 
   const targetInfo = recordingTargetV2.getInfo();
-  if (targetInfo.targetType === 'ANDROID' && !targetInfo.dynamicTargetInfo) {
+  // The absence of androidApiLevel shows that we have not connected to the
+  // device because we do not have user authorization.
+  if (targetInfo.targetType === 'ANDROID' && !targetInfo.androidApiLevel) {
     components.push(
         m('.full-centered', 'Please allow USB debugging on the device.'));
     return m('.record-container', components);
   }
 
-  const SECTIONS: {[property: string]: (cssClass: string) => m.Child} = {
-    buffers: RecSettings,
-    instructions: Instructions,
-    config: Configurations,
-    cpu: CpuSettings,
-    gpu: GpuSettings,
-    power: PowerSettings,
-    memory: MemorySettings,
-    android: AndroidSettings,
-    advanced: AdvancedSettings,
-  };
-
   const pages: m.Children = [];
   // we need to remove the `/` character from the route
   let routePage = subpage ? subpage.substr(1) : '';
-  if (!Object.keys(SECTIONS).includes(routePage)) {
+  if (!RECORDING_SECTIONS.includes(routePage)) {
     routePage = 'buffers';
   }
   pages.push(recordMenu(routePage));
-  for (const key of Object.keys(SECTIONS)) {
-    const cssClass = routePage === key ? '.active' : '';
-    pages.push(SECTIONS[key](cssClass));
+
+  pages.push(m(RecordingSettings, {
+    dataSources: [],
+    cssClass: maybeGetActiveCss(routePage, 'buffers'),
+  } as RecordingSectionAttrs));
+  pages.push(Instructions(maybeGetActiveCss(routePage, 'instructions')));
+  pages.push(Configurations(maybeGetActiveCss(routePage, 'config')));
+
+  const settingsSections = new Map([
+    ['cpu', CpuSettings],
+    ['gpu', GpuSettings],
+    ['power', PowerSettings],
+    ['memory', MemorySettings],
+    ['android', AndroidSettings],
+    ['advanced', AdvancedSettings],
+    // TODO(octaviant): Add Chrome settings.
+  ]);
+  for (const [section, component] of settingsSections.entries()) {
+    pages.push(m(component, {
+      dataSources: [],
+      cssClass: maybeGetActiveCss(routePage, section),
+    } as RecordingSectionAttrs));
   }
+
   components.push(m('.record-container-content', pages));
   return m('.record-container', components);
 }
diff --git a/ui/src/frontend/recording/advanced_settings.ts b/ui/src/frontend/recording/advanced_settings.ts
new file mode 100644
index 0000000..f04ec38
--- /dev/null
+++ b/ui/src/frontend/recording/advanced_settings.ts
@@ -0,0 +1,110 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import * as m from 'mithril';
+
+import {
+  Dropdown,
+  DropdownAttrs,
+  Probe,
+  ProbeAttrs,
+  Slider,
+  SliderAttrs,
+  Textarea,
+  TextareaAttrs,
+  Toggle,
+  ToggleAttrs,
+} from '../record_widgets';
+import {RecordingSectionAttrs} from './recording_sections';
+
+const FTRACE_CATEGORIES = new Map<string, string>();
+FTRACE_CATEGORIES.set('binder/*', 'binder');
+FTRACE_CATEGORIES.set('block/*', 'block');
+FTRACE_CATEGORIES.set('clk/*', 'clk');
+FTRACE_CATEGORIES.set('ext4/*', 'ext4');
+FTRACE_CATEGORIES.set('f2fs/*', 'f2fs');
+FTRACE_CATEGORIES.set('i2c/*', 'i2c');
+FTRACE_CATEGORIES.set('irq/*', 'irq');
+FTRACE_CATEGORIES.set('kmem/*', 'kmem');
+FTRACE_CATEGORIES.set('memory_bus/*', 'memory_bus');
+FTRACE_CATEGORIES.set('mmc/*', 'mmc');
+FTRACE_CATEGORIES.set('oom/*', 'oom');
+FTRACE_CATEGORIES.set('power/*', 'power');
+FTRACE_CATEGORIES.set('regulator/*', 'regulator');
+FTRACE_CATEGORIES.set('sched/*', 'sched');
+FTRACE_CATEGORIES.set('sync/*', 'sync');
+FTRACE_CATEGORIES.set('task/*', 'task');
+FTRACE_CATEGORIES.set('task/*', 'task');
+FTRACE_CATEGORIES.set('vmscan/*', 'vmscan');
+FTRACE_CATEGORIES.set('fastrpc/*', 'fastrpc');
+
+export class AdvancedSettings implements
+    m.ClassComponent<RecordingSectionAttrs> {
+  view({attrs}: m.CVnode<RecordingSectionAttrs>) {
+    return m(
+        `.record-section${attrs.cssClass}`,
+        m(Probe,
+          {
+            title: 'Advanced ftrace config',
+            img: 'rec_ftrace.png',
+            descr:
+                `Enable individual events and tune the kernel-tracing (ftrace)
+                  module. The events enabled here are in addition to those from
+                  enabled by other probes.`,
+            setEnabled: (cfg, val) => cfg.ftrace = val,
+            isEnabled: (cfg) => cfg.ftrace,
+          } as ProbeAttrs,
+          m(Toggle, {
+            title: 'Resolve kernel symbols',
+            cssClass: '.thin',
+            descr: `Enables lookup via /proc/kallsyms for workqueue,
+              sched_blocked_reason and other events
+              (userdebug/eng builds only).`,
+            setEnabled: (cfg, val) => cfg.symbolizeKsyms = val,
+            isEnabled: (cfg) => cfg.symbolizeKsyms,
+          } as ToggleAttrs),
+          m(Slider, {
+            title: 'Buf size',
+            cssClass: '.thin',
+            values: [0, 512, 1024, 2 * 1024, 4 * 1024, 16 * 1024, 32 * 1024],
+            unit: 'KB',
+            zeroIsDefault: true,
+            set: (cfg, val) => cfg.ftraceBufferSizeKb = val,
+            get: (cfg) => cfg.ftraceBufferSizeKb,
+          } as SliderAttrs),
+          m(Slider, {
+            title: 'Drain rate',
+            cssClass: '.thin',
+            values: [0, 100, 250, 500, 1000, 2500, 5000],
+            unit: 'ms',
+            zeroIsDefault: true,
+            set: (cfg, val) => cfg.ftraceDrainPeriodMs = val,
+            get: (cfg) => cfg.ftraceDrainPeriodMs,
+          } as SliderAttrs),
+          m(Dropdown, {
+            title: 'Event groups',
+            cssClass: '.multicolumn.ftrace-events',
+            options: FTRACE_CATEGORIES,
+            set: (cfg, val) => cfg.ftraceEvents = val,
+            get: (cfg) => cfg.ftraceEvents,
+          } as DropdownAttrs),
+          m(Textarea, {
+            placeholder: 'Add extra events, one per line, e.g.:\n' +
+                'sched/sched_switch\n' +
+                'kmem/*',
+            set: (cfg, val) => cfg.ftraceExtraEvents = val,
+            get: (cfg) => cfg.ftraceExtraEvents,
+          } as TextareaAttrs)));
+  }
+}
diff --git a/ui/src/frontend/recording/android_settings.ts b/ui/src/frontend/recording/android_settings.ts
new file mode 100644
index 0000000..f0b01fd
--- /dev/null
+++ b/ui/src/frontend/recording/android_settings.ts
@@ -0,0 +1,139 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import * as m from 'mithril';
+import {globals} from '../globals';
+import {
+  Dropdown,
+  DropdownAttrs,
+  Probe,
+  ProbeAttrs,
+  Textarea,
+  TextareaAttrs,
+  Toggle,
+  ToggleAttrs,
+} from '../record_widgets';
+import {RecordingSectionAttrs} from './recording_sections';
+
+const LOG_BUFFERS = new Map<string, string>();
+LOG_BUFFERS.set('LID_CRASH', 'Crash');
+LOG_BUFFERS.set('LID_DEFAULT', 'Main');
+LOG_BUFFERS.set('LID_EVENTS', 'Binary events');
+LOG_BUFFERS.set('LID_KERNEL', 'Kernel');
+LOG_BUFFERS.set('LID_RADIO', 'Radio');
+LOG_BUFFERS.set('LID_SECURITY', 'Security');
+LOG_BUFFERS.set('LID_STATS', 'Stats');
+LOG_BUFFERS.set('LID_SYSTEM', 'System');
+
+const ATRACE_CATEGORIES = new Map<string, string>();
+ATRACE_CATEGORIES.set('adb', 'ADB');
+ATRACE_CATEGORIES.set('aidl', 'AIDL calls');
+ATRACE_CATEGORIES.set('am', 'Activity Manager');
+ATRACE_CATEGORIES.set('audio', 'Audio');
+ATRACE_CATEGORIES.set('binder_driver', 'Binder Kernel driver');
+ATRACE_CATEGORIES.set('binder_lock', 'Binder global lock trace');
+ATRACE_CATEGORIES.set('bionic', 'Bionic C library');
+ATRACE_CATEGORIES.set('camera', 'Camera');
+ATRACE_CATEGORIES.set('dalvik', 'ART & Dalvik');
+ATRACE_CATEGORIES.set('database', 'Database');
+ATRACE_CATEGORIES.set('gfx', 'Graphics');
+ATRACE_CATEGORIES.set('hal', 'Hardware Modules');
+ATRACE_CATEGORIES.set('input', 'Input');
+ATRACE_CATEGORIES.set('network', 'Network');
+ATRACE_CATEGORIES.set('nnapi', 'Neural Network API');
+ATRACE_CATEGORIES.set('pm', 'Package Manager');
+ATRACE_CATEGORIES.set('power', 'Power Management');
+ATRACE_CATEGORIES.set('res', 'Resource Loading');
+ATRACE_CATEGORIES.set('rro', 'Resource Overlay');
+ATRACE_CATEGORIES.set('rs', 'RenderScript');
+ATRACE_CATEGORIES.set('sm', 'Sync Manager');
+ATRACE_CATEGORIES.set('ss', 'System Server');
+ATRACE_CATEGORIES.set('vibrator', 'Vibrator');
+ATRACE_CATEGORIES.set('video', 'Video');
+ATRACE_CATEGORIES.set('view', 'View System');
+ATRACE_CATEGORIES.set('webview', 'WebView');
+ATRACE_CATEGORIES.set('wm', 'Window Manager');
+
+class AtraceAppsList implements m.ClassComponent {
+  view() {
+    if (globals.state.recordConfig.allAtraceApps) {
+      return m('div');
+    }
+
+    return m(Textarea, {
+      placeholder: 'Apps to profile, one per line, e.g.:\n' +
+          'com.android.phone\n' +
+          'lmkd\n' +
+          'com.android.nfc',
+      cssClass: '.atrace-apps-list',
+      set: (cfg, val) => cfg.atraceApps = val,
+      get: (cfg) => cfg.atraceApps,
+    } as TextareaAttrs);
+  }
+}
+
+export class AndroidSettings implements
+    m.ClassComponent<RecordingSectionAttrs> {
+  view({attrs}: m.CVnode<RecordingSectionAttrs>) {
+    return m(
+        `.record-section${attrs.cssClass}`,
+        m(Probe,
+          {
+            title: 'Atrace userspace annotations',
+            img: 'rec_atrace.png',
+            descr: `Enables C++ / Java codebase annotations (ATRACE_BEGIN() /
+                      os.Trace())`,
+            setEnabled: (cfg, val) => cfg.atrace = val,
+            isEnabled: (cfg) => cfg.atrace,
+          } as ProbeAttrs,
+          m(Dropdown, {
+            title: 'Categories',
+            cssClass: '.multicolumn.atrace-categories',
+            options: ATRACE_CATEGORIES,
+            set: (cfg, val) => cfg.atraceCats = val,
+            get: (cfg) => cfg.atraceCats,
+          } as DropdownAttrs),
+          m(Toggle, {
+            title: 'Record events from all Android apps and services',
+            descr: '',
+            setEnabled: (cfg, val) => cfg.allAtraceApps = val,
+            isEnabled: (cfg) => cfg.allAtraceApps,
+          } as ToggleAttrs),
+          m(AtraceAppsList)),
+        m(Probe,
+          {
+            title: 'Event log (logcat)',
+            img: 'rec_logcat.png',
+            descr: `Streams the event log into the trace. If no buffer filter is
+                      specified, all buffers are selected.`,
+            setEnabled: (cfg, val) => cfg.androidLogs = val,
+            isEnabled: (cfg) => cfg.androidLogs,
+          } as ProbeAttrs,
+          m(Dropdown, {
+            title: 'Buffers',
+            cssClass: '.multicolumn',
+            options: LOG_BUFFERS,
+            set: (cfg, val) => cfg.androidLogBuffers = val,
+            get: (cfg) => cfg.androidLogBuffers,
+          } as DropdownAttrs)),
+        m(Probe, {
+          title: 'Frame timeline',
+          img: 'rec_frame_timeline.png',
+          descr: `Records expected/actual frame timings from surface_flinger.
+                      Requires Android 12 (S) or above.`,
+          setEnabled: (cfg, val) => cfg.androidFrameTimeline = val,
+          isEnabled: (cfg) => cfg.androidFrameTimeline,
+        } as ProbeAttrs));
+  }
+}
diff --git a/ui/src/frontend/recording/chrome_settings.ts b/ui/src/frontend/recording/chrome_settings.ts
new file mode 100644
index 0000000..3dbd2a2
--- /dev/null
+++ b/ui/src/frontend/recording/chrome_settings.ts
@@ -0,0 +1,107 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import * as m from 'mithril';
+
+import {getBuiltinChromeCategoryList, isChromeTarget} from '../../common/state';
+import {globals} from '../globals';
+import {CategoriesCheckboxList, CompactProbe} from '../record_widgets';
+
+import {RecordingSectionAttrs} from './recording_sections';
+
+class ChromeCategoriesSelection implements m.ClassComponent {
+  view() {
+    // If we are attempting to record via the Chrome extension, we receive the
+    // list of actually supported categories via DevTools. Otherwise, we fall
+    // back to an integrated list of categories from a recent version of Chrome.
+    let categories = globals.state.chromeCategories;
+    if (!categories || !isChromeTarget(globals.state.recordingTarget)) {
+      categories = getBuiltinChromeCategoryList();
+    }
+
+    const defaultCategories = new Map<string, string>();
+    const disabledByDefaultCategories = new Map<string, string>();
+    const disabledPrefix = 'disabled-by-default-';
+    categories.forEach((cat) => {
+      if (cat.startsWith(disabledPrefix)) {
+        disabledByDefaultCategories.set(cat, cat.replace(disabledPrefix, ''));
+      } else {
+        defaultCategories.set(cat, cat);
+      }
+    });
+
+    return m(
+        '.chrome-categories',
+        m(CategoriesCheckboxList, {
+          categories: defaultCategories,
+          title: 'Additional categories',
+          get: (cfg) => cfg.chromeCategoriesSelected,
+          set: (cfg, val) => cfg.chromeCategoriesSelected = val,
+        }),
+        m(CategoriesCheckboxList, {
+          categories: disabledByDefaultCategories,
+          title: 'High overhead categories',
+          get: (cfg) => cfg.chromeHighOverheadCategoriesSelected,
+          set: (cfg, val) => cfg.chromeHighOverheadCategoriesSelected = val,
+        }));
+  }
+}
+
+export class ChromeSettings implements m.ClassComponent<RecordingSectionAttrs> {
+  view({attrs}: m.CVnode<RecordingSectionAttrs>) {
+    return m(
+        `.record-section${attrs.cssClass}`,
+        CompactProbe({
+          title: 'Task scheduling',
+          setEnabled: (cfg, val) => cfg.taskScheduling = val,
+          isEnabled: (cfg) => cfg.taskScheduling,
+        }),
+        CompactProbe({
+          title: 'IPC flows',
+          setEnabled: (cfg, val) => cfg.ipcFlows = val,
+          isEnabled: (cfg) => cfg.ipcFlows,
+        }),
+        CompactProbe({
+          title: 'Javascript execution',
+          setEnabled: (cfg, val) => cfg.jsExecution = val,
+          isEnabled: (cfg) => cfg.jsExecution,
+        }),
+        CompactProbe({
+          title: 'Web content rendering, layout and compositing',
+          setEnabled: (cfg, val) => cfg.webContentRendering = val,
+          isEnabled: (cfg) => cfg.webContentRendering,
+        }),
+        CompactProbe({
+          title: 'UI rendering & surface compositing',
+          setEnabled: (cfg, val) => cfg.uiRendering = val,
+          isEnabled: (cfg) => cfg.uiRendering,
+        }),
+        CompactProbe({
+          title: 'Input events',
+          setEnabled: (cfg, val) => cfg.inputEvents = val,
+          isEnabled: (cfg) => cfg.inputEvents,
+        }),
+        CompactProbe({
+          title: 'Navigation & Loading',
+          setEnabled: (cfg, val) => cfg.navigationAndLoading = val,
+          isEnabled: (cfg) => cfg.navigationAndLoading,
+        }),
+        CompactProbe({
+          title: 'Chrome Logs',
+          setEnabled: (cfg, val) => cfg.chromeLogs = val,
+          isEnabled: (cfg) => cfg.chromeLogs,
+        }),
+        m(ChromeCategoriesSelection));
+  }
+}
diff --git a/ui/src/frontend/recording/cpu_settings.ts b/ui/src/frontend/recording/cpu_settings.ts
new file mode 100644
index 0000000..9151f4f
--- /dev/null
+++ b/ui/src/frontend/recording/cpu_settings.ts
@@ -0,0 +1,64 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import * as m from 'mithril';
+
+import {Probe, ProbeAttrs, Slider, SliderAttrs} from '../record_widgets';
+import {POLL_INTERVAL_MS, RecordingSectionAttrs} from './recording_sections';
+
+export class CpuSettings implements m.ClassComponent<RecordingSectionAttrs> {
+  view({attrs}: m.CVnode<RecordingSectionAttrs>) {
+    return m(
+        `.record-section${attrs.cssClass}`,
+        m(Probe,
+          {
+            title: 'Coarse CPU usage counter',
+            img: 'rec_cpu_coarse.png',
+            descr: `Lightweight polling of CPU usage counters via /proc/stat.
+                    Allows to periodically monitor CPU usage.`,
+            setEnabled: (cfg, val) => cfg.cpuCoarse = val,
+            isEnabled: (cfg) => cfg.cpuCoarse,
+          } as ProbeAttrs,
+          m(Slider, {
+            title: 'Poll interval',
+            cssClass: '.thin',
+            values: POLL_INTERVAL_MS,
+            unit: 'ms',
+            set: (cfg, val) => cfg.cpuCoarsePollMs = val,
+            get: (cfg) => cfg.cpuCoarsePollMs,
+          } as SliderAttrs)),
+        m(Probe, {
+          title: 'Scheduling details',
+          img: 'rec_cpu_fine.png',
+          descr: 'Enables high-detailed tracking of scheduling events',
+          setEnabled: (cfg, val) => cfg.cpuSched = val,
+          isEnabled: (cfg) => cfg.cpuSched,
+        } as ProbeAttrs),
+        m(Probe, {
+          title: 'CPU frequency and idle states',
+          img: 'rec_cpu_freq.png',
+          descr: 'Records cpu frequency and idle state changes via ftrace',
+          setEnabled: (cfg, val) => cfg.cpuFreq = val,
+          isEnabled: (cfg) => cfg.cpuFreq,
+        } as ProbeAttrs),
+        m(Probe, {
+          title: 'Syscalls',
+          img: 'rec_syscalls.png',
+          descr: `Tracks the enter and exit of all syscalls. On Android
+                requires a userdebug or eng build.`,
+          setEnabled: (cfg, val) => cfg.cpuSyscall = val,
+          isEnabled: (cfg) => cfg.cpuSyscall,
+        } as ProbeAttrs));
+  }
+}
diff --git a/ui/src/frontend/recording/gpu_settings.ts b/ui/src/frontend/recording/gpu_settings.ts
new file mode 100644
index 0000000..134c5a1
--- /dev/null
+++ b/ui/src/frontend/recording/gpu_settings.ts
@@ -0,0 +1,41 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import * as m from 'mithril';
+
+import {Probe, ProbeAttrs} from '../record_widgets';
+import {RecordingSectionAttrs} from './recording_sections';
+
+export class GpuSettings implements m.ClassComponent<RecordingSectionAttrs> {
+  view({attrs}: m.CVnode<RecordingSectionAttrs>) {
+    return m(
+        `.record-section${attrs.cssClass}`,
+        m(Probe, {
+          title: 'GPU frequency',
+          img: 'rec_cpu_freq.png',
+          descr: 'Records gpu frequency via ftrace',
+          setEnabled: (cfg, val) => cfg.gpuFreq = val,
+          isEnabled: (cfg) => cfg.gpuFreq,
+        } as ProbeAttrs),
+        m(Probe, {
+          title: 'GPU memory',
+          img: 'rec_gpu_mem_total.png',
+          descr:
+              `Allows to track per process and global total GPU memory usages.
+                (Available on recent Android 12+ kernels)`,
+          setEnabled: (cfg, val) => cfg.gpuMemTotal = val,
+          isEnabled: (cfg) => cfg.gpuMemTotal,
+        } as ProbeAttrs));
+  }
+}
diff --git a/ui/src/frontend/recording/memory_settings.ts b/ui/src/frontend/recording/memory_settings.ts
new file mode 100644
index 0000000..db03f37
--- /dev/null
+++ b/ui/src/frontend/recording/memory_settings.ts
@@ -0,0 +1,328 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import * as m from 'mithril';
+
+import {MeminfoCounters, VmstatCounters} from '../../common/protos';
+import {globals} from '../globals';
+import {
+  Dropdown,
+  DropdownAttrs,
+  Probe,
+  ProbeAttrs,
+  Slider,
+  SliderAttrs,
+  Textarea,
+  TextareaAttrs,
+  Toggle,
+  ToggleAttrs,
+} from '../record_widgets';
+
+import {POLL_INTERVAL_MS, RecordingSectionAttrs} from './recording_sections';
+
+class HeapSettings implements m.ClassComponent<RecordingSectionAttrs> {
+  view({attrs}: m.CVnode<RecordingSectionAttrs>) {
+    const valuesForMS = [
+      0,
+      1000,
+      10 * 1000,
+      30 * 1000,
+      60 * 1000,
+      5 * 60 * 1000,
+      10 * 60 * 1000,
+      30 * 60 * 1000,
+      60 * 60 * 1000,
+    ];
+    const valuesForShMemBuff = [
+      0,
+      512,
+      1024,
+      2 * 1024,
+      4 * 1024,
+      8 * 1024,
+      16 * 1024,
+      32 * 1024,
+      64 * 1024,
+      128 * 1024,
+      256 * 1024,
+      512 * 1024,
+      1024 * 1024,
+      64 * 1024 * 1024,
+      128 * 1024 * 1024,
+      256 * 1024 * 1024,
+      512 * 1024 * 1024,
+    ];
+
+    return m(
+        `.${attrs.cssClass}`,
+        m(Textarea, {
+          title: 'Names or pids of the processes to track',
+          docsLink:
+              'https://perfetto.dev/docs/data-sources/native-heap-profiler#heapprofd-targets',
+          placeholder: 'One per line, e.g.:\n' +
+              'system_server\n' +
+              'com.google.android.apps.photos\n' +
+              '1503',
+          set: (cfg, val) => cfg.hpProcesses = val,
+          get: (cfg) => cfg.hpProcesses,
+        } as TextareaAttrs),
+        m(Slider, {
+          title: 'Sampling interval',
+          cssClass: '.thin',
+          values: [
+            /* eslint-disable no-multi-spaces */
+            0,     1,     2,      4,      8,      16,      32,   64,
+            128,   256,   512,    1024,   2048,   4096,    8192, 16384,
+            32768, 65536, 131072, 262144, 524288, 1048576,
+            /* eslint-enable no-multi-spaces */
+          ],
+          unit: 'B',
+          min: 0,
+          set: (cfg, val) => cfg.hpSamplingIntervalBytes = val,
+          get: (cfg) => cfg.hpSamplingIntervalBytes,
+        } as SliderAttrs),
+        m(Slider, {
+          title: 'Continuous dumps interval ',
+          description: 'Time between following dumps (0 = disabled)',
+          cssClass: '.thin',
+          values: valuesForMS,
+          unit: 'ms',
+          min: 0,
+          set: (cfg, val) => {
+            cfg.hpContinuousDumpsInterval = val;
+          },
+          get: (cfg) => cfg.hpContinuousDumpsInterval,
+        } as SliderAttrs),
+        m(Slider, {
+          title: 'Continuous dumps phase',
+          description: 'Time before first dump',
+          cssClass: `.thin${
+              globals.state.recordConfig.hpContinuousDumpsInterval === 0 ?
+                  '.greyed-out' :
+                  ''}`,
+          values: valuesForMS,
+          unit: 'ms',
+          min: 0,
+          disabled: globals.state.recordConfig.hpContinuousDumpsInterval === 0,
+          set: (cfg, val) => cfg.hpContinuousDumpsPhase = val,
+          get: (cfg) => cfg.hpContinuousDumpsPhase,
+        } as SliderAttrs),
+        m(Slider, {
+          title: `Shared memory buffer`,
+          cssClass: '.thin',
+          values: valuesForShMemBuff.filter(
+              (value) => value === 0 || value >= 8192 && value % 4096 === 0),
+          unit: 'B',
+          min: 0,
+          set: (cfg, val) => cfg.hpSharedMemoryBuffer = val,
+          get: (cfg) => cfg.hpSharedMemoryBuffer,
+        } as SliderAttrs),
+        m(Toggle, {
+          title: 'Block client',
+          cssClass: '.thin',
+          descr: `Slow down target application if profiler cannot keep up.`,
+          setEnabled: (cfg, val) => cfg.hpBlockClient = val,
+          isEnabled: (cfg) => cfg.hpBlockClient,
+        } as ToggleAttrs),
+        m(Toggle, {
+          title: 'All custom allocators (Q+)',
+          cssClass: '.thin',
+          descr: `If the target application exposes custom allocators, also
+sample from those.`,
+          setEnabled: (cfg, val) => cfg.hpAllHeaps = val,
+          isEnabled: (cfg) => cfg.hpAllHeaps,
+        } as ToggleAttrs),
+        // TODO(hjd): Add advanced options.
+    );
+  }
+}
+
+class JavaHeapDumpSettings implements m.ClassComponent<RecordingSectionAttrs> {
+  view({attrs}: m.CVnode<RecordingSectionAttrs>) {
+    const valuesForMS = [
+      0,
+      1000,
+      10 * 1000,
+      30 * 1000,
+      60 * 1000,
+      5 * 60 * 1000,
+      10 * 60 * 1000,
+      30 * 60 * 1000,
+      60 * 60 * 1000,
+    ];
+
+    return m(
+        `.${attrs.cssClass}`,
+        m(Textarea, {
+          title: 'Names or pids of the processes to track',
+          placeholder: 'One per line, e.g.:\n' +
+              'com.android.vending\n' +
+              '1503',
+          set: (cfg, val) => cfg.jpProcesses = val,
+          get: (cfg) => cfg.jpProcesses,
+        } as TextareaAttrs),
+        m(Slider, {
+          title: 'Continuous dumps interval ',
+          description: 'Time between following dumps (0 = disabled)',
+          cssClass: '.thin',
+          values: valuesForMS,
+          unit: 'ms',
+          min: 0,
+          set: (cfg, val) => {
+            cfg.jpContinuousDumpsInterval = val;
+          },
+          get: (cfg) => cfg.jpContinuousDumpsInterval,
+        } as SliderAttrs),
+        m(Slider, {
+          title: 'Continuous dumps phase',
+          description: 'Time before first dump',
+          cssClass: `.thin${
+              globals.state.recordConfig.jpContinuousDumpsInterval === 0 ?
+                  '.greyed-out' :
+                  ''}`,
+          values: valuesForMS,
+          unit: 'ms',
+          min: 0,
+          disabled: globals.state.recordConfig.jpContinuousDumpsInterval === 0,
+          set: (cfg, val) => cfg.jpContinuousDumpsPhase = val,
+          get: (cfg) => cfg.jpContinuousDumpsPhase,
+        } as SliderAttrs),
+    );
+  }
+}
+
+export class MemorySettings implements m.ClassComponent<RecordingSectionAttrs> {
+  view({attrs}: m.CVnode<RecordingSectionAttrs>) {
+    const meminfoOpts = new Map<string, string>();
+    for (const x in MeminfoCounters) {
+      if (typeof MeminfoCounters[x] === 'number' &&
+          !`${x}`.endsWith('_UNSPECIFIED')) {
+        meminfoOpts.set(x, x.replace('MEMINFO_', '').toLowerCase());
+      }
+    }
+    const vmstatOpts = new Map<string, string>();
+    for (const x in VmstatCounters) {
+      if (typeof VmstatCounters[x] === 'number' &&
+          !`${x}`.endsWith('_UNSPECIFIED')) {
+        vmstatOpts.set(x, x.replace('VMSTAT_', '').toLowerCase());
+      }
+    }
+    return m(
+        `.record-section${attrs.cssClass}`,
+        m(Probe,
+          {
+            title: 'Native heap profiling',
+            img: 'rec_native_heap_profiler.png',
+            descr: `Track native heap allocations & deallocations of an Android
+               process. (Available on Android 10+)`,
+            setEnabled: (cfg, val) => cfg.heapProfiling = val,
+            isEnabled: (cfg) => cfg.heapProfiling,
+          } as ProbeAttrs,
+          m(HeapSettings, attrs)),
+        m(Probe,
+          {
+            title: 'Java heap dumps',
+            img: 'rec_java_heap_dump.png',
+            descr: `Dump information about the Java object graph of an
+          Android app. (Available on Android 11+)`,
+            setEnabled: (cfg, val) => cfg.javaHeapDump = val,
+            isEnabled: (cfg) => cfg.javaHeapDump,
+          } as ProbeAttrs,
+          m(JavaHeapDumpSettings, attrs)),
+        m(Probe,
+          {
+            title: 'Kernel meminfo',
+            img: 'rec_meminfo.png',
+            descr: 'Polling of /proc/meminfo',
+            setEnabled: (cfg, val) => cfg.meminfo = val,
+            isEnabled: (cfg) => cfg.meminfo,
+          } as ProbeAttrs,
+          m(Slider, {
+            title: 'Poll interval',
+            cssClass: '.thin',
+            values: POLL_INTERVAL_MS,
+            unit: 'ms',
+            set: (cfg, val) => cfg.meminfoPeriodMs = val,
+            get: (cfg) => cfg.meminfoPeriodMs,
+          } as SliderAttrs),
+          m(Dropdown, {
+            title: 'Select counters',
+            cssClass: '.multicolumn',
+            options: meminfoOpts,
+            set: (cfg, val) => cfg.meminfoCounters = val,
+            get: (cfg) => cfg.meminfoCounters,
+          } as DropdownAttrs)),
+        m(Probe, {
+          title: 'High-frequency memory events',
+          img: 'rec_mem_hifreq.png',
+          descr: `Allows to track short memory spikes and transitories through
+                ftrace's mm_event, rss_stat and ion events. Available only
+                on recent Android Q+ kernels`,
+          setEnabled: (cfg, val) => cfg.memHiFreq = val,
+          isEnabled: (cfg) => cfg.memHiFreq,
+        } as ProbeAttrs),
+        m(Probe, {
+          title: 'Low memory killer',
+          img: 'rec_lmk.png',
+          descr: `Record LMK events. Works both with the old in-kernel LMK
+                and the newer userspace lmkd. It also tracks OOM score
+                adjustments.`,
+          setEnabled: (cfg, val) => cfg.memLmk = val,
+          isEnabled: (cfg) => cfg.memLmk,
+        } as ProbeAttrs),
+        m(Probe,
+          {
+            title: 'Per process stats',
+            img: 'rec_ps_stats.png',
+            descr: `Periodically samples all processes in the system tracking:
+                    their thread list, memory counters (RSS, swap and other
+                    /proc/status counters) and oom_score_adj.`,
+            setEnabled: (cfg, val) => cfg.procStats = val,
+            isEnabled: (cfg) => cfg.procStats,
+          } as ProbeAttrs,
+          m(Slider, {
+            title: 'Poll interval',
+            cssClass: '.thin',
+            values: POLL_INTERVAL_MS,
+            unit: 'ms',
+            set: (cfg, val) => cfg.procStatsPeriodMs = val,
+            get: (cfg) => cfg.procStatsPeriodMs,
+          } as SliderAttrs)),
+        m(Probe,
+          {
+            title: 'Virtual memory stats',
+            img: 'rec_vmstat.png',
+            descr: `Periodically polls virtual memory stats from /proc/vmstat.
+                    Allows to gather statistics about swap, eviction,
+                    compression and pagecache efficiency`,
+            setEnabled: (cfg, val) => cfg.vmstat = val,
+            isEnabled: (cfg) => cfg.vmstat,
+          } as ProbeAttrs,
+          m(Slider, {
+            title: 'Poll interval',
+            cssClass: '.thin',
+            values: POLL_INTERVAL_MS,
+            unit: 'ms',
+            set: (cfg, val) => cfg.vmstatPeriodMs = val,
+            get: (cfg) => cfg.vmstatPeriodMs,
+          } as SliderAttrs),
+          m(Dropdown, {
+            title: 'Select counters',
+            cssClass: '.multicolumn',
+            options: vmstatOpts,
+            set: (cfg, val) => cfg.vmstatCounters = val,
+            get: (cfg) => cfg.vmstatCounters,
+          } as DropdownAttrs)));
+  }
+}
diff --git a/ui/src/frontend/recording/power_settings.ts b/ui/src/frontend/recording/power_settings.ts
new file mode 100644
index 0000000..bd8bfe1
--- /dev/null
+++ b/ui/src/frontend/recording/power_settings.ts
@@ -0,0 +1,69 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import * as m from 'mithril';
+
+import {globals} from '../globals';
+import {Probe, ProbeAttrs, Slider, SliderAttrs} from '../record_widgets';
+import {POLL_INTERVAL_MS, RecordingSectionAttrs} from './recording_sections';
+
+export class PowerSettings implements m.ClassComponent<RecordingSectionAttrs> {
+  view({attrs}: m.CVnode<RecordingSectionAttrs>) {
+    const DOC_URL = 'https://perfetto.dev/docs/data-sources/battery-counters';
+    const descr =
+        [m('div',
+           m('span', `Polls charge counters and instantaneous power draw from
+                    the battery power management IC and the power rails from
+                    the PowerStats HAL (`),
+           m('a', {href: DOC_URL, target: '_blank'}, 'see docs for more'),
+           m('span', ')'))];
+    if (globals.isInternalUser) {
+      descr.push(m(
+          'div',
+          m('span', 'Googlers: See '),
+          m('a',
+            {href: 'http://go/power-rails-internal-doc', target: '_blank'},
+            'this doc'),
+          m('span',
+            ` for instructions on how to change the refault rail selection
+                  on internal devices.`),
+          ));
+    }
+    return m(
+        `.record-section${attrs.cssClass}`,
+        m(Probe,
+          {
+            title: 'Battery drain & power rails',
+            img: 'rec_battery_counters.png',
+            descr,
+            setEnabled: (cfg, val) => cfg.batteryDrain = val,
+            isEnabled: (cfg) => cfg.batteryDrain,
+          } as ProbeAttrs,
+          m(Slider, {
+            title: 'Poll interval',
+            cssClass: '.thin',
+            values: POLL_INTERVAL_MS,
+            unit: 'ms',
+            set: (cfg, val) => cfg.batteryDrainPollMs = val,
+            get: (cfg) => cfg.batteryDrainPollMs,
+          } as SliderAttrs)),
+        m(Probe, {
+          title: 'Board voltages & frequencies',
+          img: 'rec_board_voltage.png',
+          descr: 'Tracks voltage and frequency changes from board sensors',
+          setEnabled: (cfg, val) => cfg.boardSensors = val,
+          isEnabled: (cfg) => cfg.boardSensors,
+        } as ProbeAttrs));
+  }
+}
diff --git a/ui/src/frontend/recording/recording_sections.ts b/ui/src/frontend/recording/recording_sections.ts
new file mode 100644
index 0000000..f0e3fa1
--- /dev/null
+++ b/ui/src/frontend/recording/recording_sections.ts
@@ -0,0 +1,22 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {DataSource} from '../../common/recordingV2/recording_interfaces_v2';
+
+export interface RecordingSectionAttrs {
+  dataSources: DataSource[];
+  cssClass: string;
+}
+
+export const POLL_INTERVAL_MS = [250, 500, 1000, 2500, 5000, 30000, 60000];
diff --git a/ui/src/frontend/recording/recording_settings.ts b/ui/src/frontend/recording/recording_settings.ts
new file mode 100644
index 0000000..900fe44
--- /dev/null
+++ b/ui/src/frontend/recording/recording_settings.ts
@@ -0,0 +1,98 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {produce} from 'immer';
+import * as m from 'mithril';
+
+import {Actions} from '../../common/actions';
+import {RecordMode} from '../../common/state';
+import {globals} from '../globals';
+import {Slider, SliderAttrs} from '../record_widgets';
+
+import {RecordingSectionAttrs} from './recording_sections';
+
+export class RecordingSettings implements
+    m.ClassComponent<RecordingSectionAttrs> {
+  view({attrs}: m.CVnode<RecordingSectionAttrs>) {
+    const S = (x: number) => x * 1000;
+    const M = (x: number) => x * 1000 * 60;
+    const H = (x: number) => x * 1000 * 60 * 60;
+
+    const cfg = globals.state.recordConfig;
+
+    const recButton = (mode: RecordMode, title: string, img: string) => {
+      const checkboxArgs = {
+        checked: cfg.mode === mode,
+        onchange: (e: InputEvent) => {
+          const checked = (e.target as HTMLInputElement).checked;
+          if (!checked) return;
+          const traceCfg = produce(globals.state.recordConfig, (draft) => {
+            draft.mode = mode;
+          });
+          globals.dispatch(Actions.setRecordConfig({config: traceCfg}));
+        },
+      };
+      return m(
+          `label${cfg.mode === mode ? '.selected' : ''}`,
+          m(`input[type=radio][name=rec_mode]`, checkboxArgs),
+          m(`img[src=${globals.root}assets/${img}]`),
+          m('span', title));
+    };
+
+    return m(
+        `.record-section${attrs.cssClass}`,
+        m('header', 'Recording mode'),
+        m('.record-mode',
+          recButton('STOP_WHEN_FULL', 'Stop when full', 'rec_one_shot.png'),
+          recButton('RING_BUFFER', 'Ring buffer', 'rec_ring_buf.png'),
+          recButton('LONG_TRACE', 'Long trace', 'rec_long_trace.png')),
+
+        m(Slider, {
+          title: 'In-memory buffer size',
+          icon: '360',
+          values: [4, 8, 16, 32, 64, 128, 256, 512],
+          unit: 'MB',
+          set: (cfg, val) => cfg.bufferSizeMb = val,
+          get: (cfg) => cfg.bufferSizeMb,
+        } as SliderAttrs),
+
+        m(Slider, {
+          title: 'Max duration',
+          icon: 'timer',
+          values: [S(10), S(15), S(30), S(60), M(5), M(30), H(1), H(6), H(12)],
+          isTime: true,
+          unit: 'h:m:s',
+          set: (cfg, val) => cfg.durationMs = val,
+          get: (cfg) => cfg.durationMs,
+        } as SliderAttrs),
+        m(Slider, {
+          title: 'Max file size',
+          icon: 'save',
+          cssClass: cfg.mode !== 'LONG_TRACE' ? '.hide' : '',
+          values: [5, 25, 50, 100, 500, 1000, 1000 * 5, 1000 * 10],
+          unit: 'MB',
+          set: (cfg, val) => cfg.maxFileSizeMb = val,
+          get: (cfg) => cfg.maxFileSizeMb,
+        } as SliderAttrs),
+        m(Slider, {
+          title: 'Flush on disk every',
+          cssClass: cfg.mode !== 'LONG_TRACE' ? '.hide' : '',
+          icon: 'av_timer',
+          values: [100, 250, 500, 1000, 2500, 5000],
+          unit: 'ms',
+          set: (cfg, val) => cfg.fileWritePeriodMs = val,
+          get: (cfg) => cfg.fileWritePeriodMs || 0,
+        } as SliderAttrs));
+  }
+}
diff --git a/ui/src/test/diff_viewer/README.md b/ui/src/test/diff_viewer/README.md
new file mode 100644
index 0000000..119dd75
--- /dev/null
+++ b/ui/src/test/diff_viewer/README.md
@@ -0,0 +1,18 @@
+# CI screenshot diff viewer
+
+This directory contains the source of screenshots diff viewer used on Perfetto
+CI. The way it works as follows:
+
+When a screenshot test is failing, the testing code will write a line of the
+form
+
+```
+failed-screenshot.png;failed-screenshot-diff.png
+```
+
+To a file called `report.txt`. Diff viewer is just a static page that uses Fetch
+API to download this file, parse it, and display images in a list of rows.
+
+The page assumes `report.txt` to be present in the same directory, same goes for
+screenshot files. To simplify deployment, the viewer is developed without a
+framework and constructs DOM using `document.createElement` API.
diff --git a/ui/src/test/diff_viewer/index.html b/ui/src/test/diff_viewer/index.html
new file mode 100644
index 0000000..d4742b4
--- /dev/null
+++ b/ui/src/test/diff_viewer/index.html
@@ -0,0 +1,27 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+	<meta charset="UTF-8">
+	<meta http-equiv="X-UA-Compatible" content="IE=edge">
+	<meta name="viewport" content="width=device-width, initial-scale=1.0">
+	<title>Diff screenshots report</title>
+	<style>
+		.row {
+			display: flex;
+			padding: 1rem;
+			border-radius: .5rem;
+			border: 1px solid black;
+			margin-bottom: 1rem;
+		}
+		.image-wrapper img {
+			max-width: 45vw;
+		}
+	</style>
+</head>
+<body>
+	<div class="container">
+		Loading...
+	</div>
+	<script src="script.js"></script>
+</body>
+</html>
\ No newline at end of file
diff --git a/ui/src/test/diff_viewer/script.js b/ui/src/test/diff_viewer/script.js
new file mode 100644
index 0000000..97a6f64
--- /dev/null
+++ b/ui/src/test/diff_viewer/script.js
@@ -0,0 +1,79 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Helper function to create DOM elements faster: takes a Mithril-style
+// "selector" of the form "tag.class1.class2" and a list of child objects that
+// can be either strings or DOM elements.
+function m(selector, ...children) {
+  const parts = selector.split('.');
+  if (parts.length === 0) {
+    throw new Error(
+        'Selector passed to element should be of a form tag.class1.class2');
+  }
+
+  const result = document.createElement(parts[0]);
+  for (let i = 1; i < parts.length; i++) {
+    result.classList.add(parts[i]);
+  }
+  for (const child of children) {
+    if (typeof child === 'string') {
+      const childNode = document.createTextNode(child);
+      result.appendChild(childNode);
+    } else {
+      result.appendChild(child);
+    }
+  }
+  return result;
+}
+
+async function loadDiffs() {
+  // report.txt is a text file with a pair of file names on each line, separated
+  // by semicolon. E.g. "screenshot.png;screenshot-diff.png"
+  const report = await fetch('report.txt');
+  const response = await report.text();
+  console.log(response);
+
+  const container = document.querySelector('.container');
+  container.innerHTML = '';
+
+  const lines = response.split('\n');
+  for (const line of lines) {
+    const parts = line.split(';');
+    if (parts.length !== 2) {
+      console.warn(
+          `Malformed line (expected two files separated via semicolon) ${
+              line}!`);
+      continue;
+    }
+
+    const [output, diff] = parts;
+    const outputImage = m('img');
+    outputImage.src = output;
+    const diffImage = m('img');
+    diffImage.src = diff;
+
+    container.appendChild(
+        m('div.row',
+          m('div.cell', output, m('div.image-wrapper', outputImage)),
+          m('div.cell', diff, m('div.image-wrapper', diffImage))));
+  }
+
+  if (lines.length === 0) {
+    container.appendChild(m('div', 'All good!'));
+  }
+}
+
+document.addEventListener('DOMContentLoaded', () => {
+  loadDiffs();
+});
diff --git a/ui/src/test/perfetto_ui_test_helper.ts b/ui/src/test/perfetto_ui_test_helper.ts
index 44e927c..97c9bd4 100644
--- a/ui/src/test/perfetto_ui_test_helper.ts
+++ b/ui/src/test/perfetto_ui_test_helper.ts
@@ -84,7 +84,7 @@
 }
 
 export async function compareScreenshots(
-    actualFilename: string, expectedFilename: string) {
+    reportPath: string, actualFilename: string, expectedFilename: string) {
   if (!fs.existsSync(expectedFilename)) {
     throw new Error(
         `Could not find ${expectedFilename}. Run wih REBASELINE=1.`);
@@ -102,6 +102,9 @@
   if (diff > DIFF_MAX_PIXELS) {
     const diffFilename = actualFilename.replace('.png', '-diff.png');
     fs.writeFileSync(diffFilename, PNG.sync.write(diffPng));
+    fs.appendFileSync(
+        reportPath,
+        `${path.basename(actualFilename)};${path.basename(diffFilename)}\n`);
     fail(`Diff test failed on ${diffFilename}, delta: ${diff} pixels`);
   }
   return diff;
diff --git a/ui/src/test/ui_integrationtest.ts b/ui/src/test/ui_integrationtest.ts
index 24a6533..4794d8d 100644
--- a/ui/src/test/ui_integrationtest.ts
+++ b/ui/src/test/ui_integrationtest.ts
@@ -28,6 +28,8 @@
 declare let global: {__BROWSER__: puppeteer.Browser;};
 const browser = assertExists(global.__BROWSER__);
 const expectedScreenshotPath = path.join('test', 'data', 'ui-screenshots');
+const tmpDir = path.resolve('./ui-test-artifacts');
+const reportPath = path.join(tmpDir, 'report.txt');
 
 async function getPage(): Promise<puppeteer.Page> {
   const pages = (await browser.pages());
@@ -41,6 +43,9 @@
   jest.setTimeout(60000);
   const page = await getPage();
   await page.setViewport({width: 1920, height: 1080});
+
+  // Empty the file with collected screenshot diffs
+  fs.writeFileSync(reportPath, '');
 });
 
 // After each test (regardless of nesting) capture a screenshot named after the
@@ -51,10 +56,6 @@
   testName = testName.replace(/[^a-z0-9-]/gmi, '_').toLowerCase();
   const page = await getPage();
 
-  // cwd() is set to //out/ui when running tests, just create a subdir in there.
-  // The CI picks up this directory and uploads to GCS after every failed run.
-  const tmpDir = path.resolve('./ui-test-artifacts');
-  if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir);
   const screenshotName = `ui-${testName}.png`;
   const actualFilename = path.join(tmpDir, screenshotName);
   const expectedFilename = path.join(expectedScreenshotPath, screenshotName);
@@ -64,7 +65,7 @@
     console.log('Saving reference screenshot into', expectedFilename);
     fs.copyFileSync(actualFilename, expectedFilename);
   } else {
-    await compareScreenshots(actualFilename, expectedFilename);
+    await compareScreenshots(reportPath, actualFilename, expectedFilename);
   }
 });