ui+tp: ProcessState explorer (dumpsys activity process/service/provider graph)
The data behind `dumpsys activity`, end to end:
- protos: ProcessStateSnapshot (TracePacket 1002).
- trace_processor: process_state_importer plugin -> intrinsic tables, resolving
enum values to names so the UI keeps no enum maps.
- UI (com.android.ProcessState): a snapshot-timeline explorer — the importance
graph (processes in oom-adj tier columns, service/provider binding edges),
per-snapshot diffing, and a responsive split-panel details view. The snapshot
track is a summary group with six nested per-importance-tier process-count
counters.
Perf: only _ps_snapshot is materialized (its gap-free slice dur is a LEAD()
window that would otherwise recompute per fetch); the tier counts are
aggregated group-first (aggregate the process table by snapshot_id, then join
the small result) rather than joining the two big intrinsics and grouping
after. Empty cells render as SQL NULL. Also drops the per-slice
extract_arg('correlation_id') from the default track-event slice dataset.
The proc_state interval breakdown tracks live in a separate plugin.
diff --git a/Android.bp b/Android.bp
index 1175c3c..17f8afe 100644
--- a/Android.bp
+++ b/Android.bp
@@ -3063,6 +3063,7 @@
":perfetto_src_trace_processor_plugins_perf_counter_perf_counter",
":perfetto_src_trace_processor_plugins_perfetto_manifest_perfetto_manifest",
":perfetto_src_trace_processor_plugins_pprof_functions_pprof_functions",
+ ":perfetto_src_trace_processor_plugins_process_state_importer_process_state_importer",
":perfetto_src_trace_processor_plugins_slice_mipmap_operator_slice_mipmap_operator",
":perfetto_src_trace_processor_plugins_span_join_operator_span_join_operator",
":perfetto_src_trace_processor_plugins_sql_stats_table_sql_stats_table",
@@ -3326,6 +3327,7 @@
"perfetto_src_trace_processor_plugins_experimental_flat_slice_tables",
"perfetto_src_trace_processor_plugins_experimental_slice_layout_tables",
"perfetto_src_trace_processor_plugins_graph_traversal_tables",
+ "perfetto_src_trace_processor_plugins_process_state_importer_tables",
"perfetto_src_trace_processor_plugins_stdlib_docs_tables",
"perfetto_src_trace_processor_plugins_structural_tree_partition_tables",
"perfetto_src_trace_processor_plugins_table_info_tables",
@@ -19321,6 +19323,45 @@
],
}
+// GN: //src/trace_processor/plugins/process_state_importer:process_state_importer
+filegroup {
+ name: "perfetto_src_trace_processor_plugins_process_state_importer_process_state_importer",
+ srcs: [
+ "src/trace_processor/plugins/process_state_importer/process_state_importer.cc",
+ "src/trace_processor/plugins/process_state_importer/process_state_module.cc",
+ ],
+}
+
+// GN: //src/trace_processor/plugins/process_state_importer:tables
+genrule {
+ name: "perfetto_src_trace_processor_plugins_process_state_importer_tables",
+ srcs: [
+ "src/trace_processor/plugins/process_state_importer/tables.py",
+ ],
+ tools: [
+ "perfetto_src_trace_processor_plugins_process_state_importer_tables_binary",
+ ],
+ cmd: "$(location perfetto_src_trace_processor_plugins_process_state_importer_tables_binary) --gen-dir=$(genDir) --relative-input-dir=external/perfetto --inputs $(in)",
+ out: [
+ "src/trace_processor/plugins/process_state_importer/all_tables_fwd.h",
+ "src/trace_processor/plugins/process_state_importer/tables_fwd.h",
+ "src/trace_processor/plugins/process_state_importer/tables_py.h",
+ ],
+}
+
+// GN: //src/trace_processor/plugins/process_state_importer:tables
+python_binary_host {
+ name: "perfetto_src_trace_processor_plugins_process_state_importer_tables_binary",
+ srcs: [
+ "python/generators/trace_processor_table/public.py",
+ "python/generators/trace_processor_table/serialize.py",
+ "python/generators/trace_processor_table/util.py",
+ "src/trace_processor/plugins/process_state_importer/tables.py",
+ "tools/gen_tp_table_headers.py",
+ ],
+ main: "tools/gen_tp_table_headers.py",
+}
+
// GN: //src/trace_processor/plugins/slice_mipmap_operator:slice_mipmap_operator
filegroup {
name: "perfetto_src_trace_processor_plugins_slice_mipmap_operator_slice_mipmap_operator",
@@ -20309,6 +20350,7 @@
":perfetto_src_trace_processor_plugins_perf_counter_perf_counter",
":perfetto_src_trace_processor_plugins_perfetto_manifest_perfetto_manifest",
":perfetto_src_trace_processor_plugins_pprof_functions_pprof_functions",
+ ":perfetto_src_trace_processor_plugins_process_state_importer_process_state_importer",
":perfetto_src_trace_processor_plugins_slice_mipmap_operator_slice_mipmap_operator",
":perfetto_src_trace_processor_plugins_span_join_operator_span_join_operator",
":perfetto_src_trace_processor_plugins_sql_stats_table_sql_stats_table",
@@ -20481,6 +20523,7 @@
"perfetto_src_trace_processor_plugins_experimental_flat_slice_tables",
"perfetto_src_trace_processor_plugins_experimental_slice_layout_tables",
"perfetto_src_trace_processor_plugins_graph_traversal_tables",
+ "perfetto_src_trace_processor_plugins_process_state_importer_tables",
"perfetto_src_trace_processor_plugins_stdlib_docs_tables",
"perfetto_src_trace_processor_plugins_structural_tree_partition_tables",
"perfetto_src_trace_processor_plugins_table_info_tables",
@@ -20583,6 +20626,7 @@
"perfetto_src_trace_processor_plugins_experimental_flat_slice_tables",
"perfetto_src_trace_processor_plugins_experimental_slice_layout_tables",
"perfetto_src_trace_processor_plugins_graph_traversal_tables",
+ "perfetto_src_trace_processor_plugins_process_state_importer_tables",
"perfetto_src_trace_processor_plugins_stdlib_docs_tables",
"perfetto_src_trace_processor_plugins_structural_tree_partition_tables",
"perfetto_src_trace_processor_plugins_table_info_tables",
@@ -22917,6 +22961,7 @@
":perfetto_src_trace_processor_plugins_perf_counter_perf_counter",
":perfetto_src_trace_processor_plugins_perfetto_manifest_perfetto_manifest",
":perfetto_src_trace_processor_plugins_pprof_functions_pprof_functions",
+ ":perfetto_src_trace_processor_plugins_process_state_importer_process_state_importer",
":perfetto_src_trace_processor_plugins_slice_mipmap_operator_slice_mipmap_operator",
":perfetto_src_trace_processor_plugins_span_join_operator_span_join_operator",
":perfetto_src_trace_processor_plugins_span_join_operator_unittests",
@@ -23290,6 +23335,7 @@
"perfetto_src_trace_processor_plugins_experimental_flat_slice_tables",
"perfetto_src_trace_processor_plugins_experimental_slice_layout_tables",
"perfetto_src_trace_processor_plugins_graph_traversal_tables",
+ "perfetto_src_trace_processor_plugins_process_state_importer_tables",
"perfetto_src_trace_processor_plugins_stdlib_docs_tables",
"perfetto_src_trace_processor_plugins_structural_tree_partition_tables",
"perfetto_src_trace_processor_plugins_table_info_tables",
@@ -23932,6 +23978,7 @@
":perfetto_src_trace_processor_plugins_perf_counter_perf_counter",
":perfetto_src_trace_processor_plugins_perfetto_manifest_perfetto_manifest",
":perfetto_src_trace_processor_plugins_pprof_functions_pprof_functions",
+ ":perfetto_src_trace_processor_plugins_process_state_importer_process_state_importer",
":perfetto_src_trace_processor_plugins_slice_mipmap_operator_slice_mipmap_operator",
":perfetto_src_trace_processor_plugins_span_join_operator_span_join_operator",
":perfetto_src_trace_processor_plugins_sql_stats_table_sql_stats_table",
@@ -24087,6 +24134,7 @@
"perfetto_src_trace_processor_plugins_experimental_flat_slice_tables",
"perfetto_src_trace_processor_plugins_experimental_slice_layout_tables",
"perfetto_src_trace_processor_plugins_graph_traversal_tables",
+ "perfetto_src_trace_processor_plugins_process_state_importer_tables",
"perfetto_src_trace_processor_plugins_stdlib_docs_tables",
"perfetto_src_trace_processor_plugins_structural_tree_partition_tables",
"perfetto_src_trace_processor_plugins_table_info_tables",
@@ -24186,6 +24234,7 @@
"perfetto_src_trace_processor_plugins_experimental_flat_slice_tables",
"perfetto_src_trace_processor_plugins_experimental_slice_layout_tables",
"perfetto_src_trace_processor_plugins_graph_traversal_tables",
+ "perfetto_src_trace_processor_plugins_process_state_importer_tables",
"perfetto_src_trace_processor_plugins_stdlib_docs_tables",
"perfetto_src_trace_processor_plugins_structural_tree_partition_tables",
"perfetto_src_trace_processor_plugins_table_info_tables",
@@ -24595,6 +24644,7 @@
":perfetto_src_trace_processor_plugins_perf_counter_perf_counter",
":perfetto_src_trace_processor_plugins_perfetto_manifest_perfetto_manifest",
":perfetto_src_trace_processor_plugins_pprof_functions_pprof_functions",
+ ":perfetto_src_trace_processor_plugins_process_state_importer_process_state_importer",
":perfetto_src_trace_processor_plugins_slice_mipmap_operator_slice_mipmap_operator",
":perfetto_src_trace_processor_plugins_span_join_operator_span_join_operator",
":perfetto_src_trace_processor_plugins_sql_stats_table_sql_stats_table",
@@ -24760,6 +24810,7 @@
"perfetto_src_trace_processor_plugins_experimental_flat_slice_tables",
"perfetto_src_trace_processor_plugins_experimental_slice_layout_tables",
"perfetto_src_trace_processor_plugins_graph_traversal_tables",
+ "perfetto_src_trace_processor_plugins_process_state_importer_tables",
"perfetto_src_trace_processor_plugins_stdlib_docs_tables",
"perfetto_src_trace_processor_plugins_structural_tree_partition_tables",
"perfetto_src_trace_processor_plugins_table_info_tables",
diff --git a/BUILD b/BUILD
index 983179f..0b1e86f 100644
--- a/BUILD
+++ b/BUILD
@@ -499,6 +499,8 @@
":src_trace_processor_plugins_perf_counter_perf_counter",
":src_trace_processor_plugins_perfetto_manifest_perfetto_manifest",
":src_trace_processor_plugins_pprof_functions_pprof_functions",
+ ":src_trace_processor_plugins_process_state_importer_process_state_importer",
+ ":src_trace_processor_plugins_process_state_importer_tables",
":src_trace_processor_plugins_slice_mipmap_operator_slice_mipmap_operator",
":src_trace_processor_plugins_span_join_operator_span_join_operator",
":src_trace_processor_plugins_sql_stats_table_sql_stats_table",
@@ -796,6 +798,8 @@
":src_trace_processor_plugins_perf_counter_perf_counter",
":src_trace_processor_plugins_perfetto_manifest_perfetto_manifest",
":src_trace_processor_plugins_pprof_functions_pprof_functions",
+ ":src_trace_processor_plugins_process_state_importer_process_state_importer",
+ ":src_trace_processor_plugins_process_state_importer_tables",
":src_trace_processor_plugins_slice_mipmap_operator_slice_mipmap_operator",
":src_trace_processor_plugins_span_join_operator_span_join_operator",
":src_trace_processor_plugins_sql_stats_table_sql_stats_table",
@@ -4813,6 +4817,30 @@
],
)
+# GN target: //src/trace_processor/plugins/process_state_importer:process_state_importer
+perfetto_filegroup(
+ name = "src_trace_processor_plugins_process_state_importer_process_state_importer",
+ srcs = [
+ "src/trace_processor/plugins/process_state_importer/process_state_importer.cc",
+ "src/trace_processor/plugins/process_state_importer/process_state_importer.h",
+ "src/trace_processor/plugins/process_state_importer/process_state_module.cc",
+ "src/trace_processor/plugins/process_state_importer/process_state_module.h",
+ ],
+)
+
+# GN target: //src/trace_processor/plugins/process_state_importer:tables
+perfetto_cc_tp_tables(
+ name = "src_trace_processor_plugins_process_state_importer_tables",
+ srcs = [
+ "src/trace_processor/plugins/process_state_importer/tables.py",
+ ],
+ outs = [
+ "src/trace_processor/plugins/process_state_importer/all_tables_fwd.h",
+ "src/trace_processor/plugins/process_state_importer/tables_fwd.h",
+ "src/trace_processor/plugins/process_state_importer/tables_py.h",
+ ],
+)
+
# GN target: //src/trace_processor/plugins/slice_mipmap_operator:slice_mipmap_operator
perfetto_filegroup(
name = "src_trace_processor_plugins_slice_mipmap_operator_slice_mipmap_operator",
@@ -11039,6 +11067,8 @@
":src_trace_processor_plugins_perf_counter_perf_counter",
":src_trace_processor_plugins_perfetto_manifest_perfetto_manifest",
":src_trace_processor_plugins_pprof_functions_pprof_functions",
+ ":src_trace_processor_plugins_process_state_importer_process_state_importer",
+ ":src_trace_processor_plugins_process_state_importer_tables",
":src_trace_processor_plugins_slice_mipmap_operator_slice_mipmap_operator",
":src_trace_processor_plugins_span_join_operator_span_join_operator",
":src_trace_processor_plugins_sql_stats_table_sql_stats_table",
@@ -11366,6 +11396,8 @@
":src_trace_processor_plugins_perf_counter_perf_counter",
":src_trace_processor_plugins_perfetto_manifest_perfetto_manifest",
":src_trace_processor_plugins_pprof_functions_pprof_functions",
+ ":src_trace_processor_plugins_process_state_importer_process_state_importer",
+ ":src_trace_processor_plugins_process_state_importer_tables",
":src_trace_processor_plugins_slice_mipmap_operator_slice_mipmap_operator",
":src_trace_processor_plugins_span_join_operator_span_join_operator",
":src_trace_processor_plugins_sql_stats_table_sql_stats_table",
diff --git a/protos/third_party/android/frameworks/base/proto/tracing/frameworks_base_trace_packet.proto b/protos/third_party/android/frameworks/base/proto/tracing/frameworks_base_trace_packet.proto
index 9104e6e..6a7544f 100644
--- a/protos/third_party/android/frameworks/base/proto/tracing/frameworks_base_trace_packet.proto
+++ b/protos/third_party/android/frameworks/base/proto/tracing/frameworks_base_trace_packet.proto
@@ -106,10 +106,75 @@
optional Reason reason = 2;
}
+// A point-in-time snapshot of the process / service / provider importance graph
+// (the data behind `dumpsys activity` and the OomAdjuster). This is the
+// snapshot counterpart to the incremental Android{Process,Service,Provider}-
+// StateChangedEvent track events: a process node carries the same fields those
+// events do (ProcessStateEnum proc state, oom score, ProcessCapabilityEnum
+// flags), and bindings mirror the service / provider connection edges.
+//
+// Emitted one packet per snapshot by the android.process_state_snapshot data
+// source and decoded by the process_state_importer trace_processor plugin into
+// intrinsic tables (no vendored AMS proto). Enum-valued fields are plain ints
+// carrying the values of the enums named below; the importer resolves them to
+// names via the generated <Enum>_Name() helpers.
+message ProcessStateSnapshot {
+ // A running process (a node in the graph).
+ message Process {
+ optional int32 pid = 1;
+ optional int32 uid = 2;
+ optional string name = 3;
+ // oom_adj score (lower = more important).
+ optional int32 oom_score = 4;
+ // Current process state; values of ProcessStateEnum.
+ optional int32 proc_state = 5;
+ // Bitmask of ProcessCapabilityEnum (bit i set => capability with enum value
+ // i+1 is granted).
+ optional int32 capability_flags = 6;
+ optional bool persistent = 7;
+ }
+ // A running service, referenced by ServiceBinding.service_id.
+ message Service {
+ // Per-snapshot id, unique within this packet.
+ optional int32 id = 1;
+ optional int32 owning_pid = 2;
+ optional string name = 3; // component / short instance name
+ // Bitmask of ServiceInfo.FOREGROUND_SERVICE_TYPE_* (0 if not an FGS).
+ optional int32 foreground_service_type = 4;
+ }
+ // A client process bound to a service (an edge).
+ message ServiceBinding {
+ optional int32 client_pid = 1;
+ optional int32 service_id = 2; // -> Service.id within this packet
+ optional bool foreground = 3; // Context.BIND_FOREGROUND_SERVICE
+ }
+ // A running content provider, referenced by ProviderBinding.provider_id.
+ message Provider {
+ // Per-snapshot id, unique within this packet.
+ optional int32 id = 1;
+ optional int32 owning_pid = 2;
+ optional string authority = 3;
+ }
+ // A client process referencing a content provider (an edge).
+ message ProviderBinding {
+ optional int32 client_pid = 1;
+ optional int32 provider_id = 2; // -> Provider.id within this packet
+ optional bool stable = 3; // ContentProviderConnection.stable
+ }
+ repeated Process processes = 1;
+ repeated Service services = 2;
+ repeated ServiceBinding service_bindings = 3;
+ repeated Provider providers = 4;
+ repeated ProviderBinding provider_bindings = 5;
+}
+
message FrameworksBaseTracePacket {
extend perfetto.protos.TracePacket {
optional AppWakelockBundle app_wakelock_bundle = 116;
optional VideoFrame video_frame = 1000;
optional VideoFrameError video_frame_error = 1001;
+ // Process / service / provider importance graph snapshot (dumpsys
+ // activity).
+ optional ProcessStateSnapshot process_state_snapshot = 1002;
}
}
diff --git a/protos/third_party/android/frameworks/base/proto/tracing/frameworks_base_track_event.proto b/protos/third_party/android/frameworks/base/proto/tracing/frameworks_base_track_event.proto
index 8c3d066..b0e4bbe 100644
--- a/protos/third_party/android/frameworks/base/proto/tracing/frameworks_base_track_event.proto
+++ b/protos/third_party/android/frameworks/base/proto/tracing/frameworks_base_track_event.proto
@@ -277,6 +277,9 @@
}
enum Importance {
+ // No allow_alias: pbzero generates an enum->name switch in the header, and a
+ // duplicate value (the old IMPORTANCE_BACKGROUND == IMPORTANCE_CACHED == 400)
+ // is a duplicate-case error under -Werror once a C++ consumer includes it.
IMPORTANCE_UNKNOWN = 0;
IMPORTANCE_FOREGROUND = 100;
IMPORTANCE_FOREGROUND_SERVICE = 125;
@@ -586,6 +589,14 @@
optional int32 freeze_policy_flags = 13;
}
+enum ServiceRestartReason {
+ SERVICE_RESTART_REASON_UNKNOWN = 0;
+ SERVICE_RESTART_REASON_START_REQUESTED = 1;
+ SERVICE_RESTART_REASON_CONNECTION = 2;
+ SERVICE_RESTART_REASON_FORCED = 3;
+ SERVICE_RESTART_REASON_PERSISTENT = 4;
+}
+
message AndroidServiceStateChangedEvent {
// UID of the app hosting the service.
optional int32 uid = 1;
@@ -599,6 +610,26 @@
optional int32 bind_id = 5;
// Type of foreground service.
optional int32 foreground_service_type = 6;
+ // UID of the app accessing the service.
+ optional int32 caller_uid = 7;
+ // PID of the app accessing the service.
+ optional int32 caller_pid = 8;
+ // Why the service was (re)started.
+ optional ServiceRestartReason reason_enum = 9;
+ // Whether the binding requested BIND_FOREGROUND_SERVICE.
+ optional bool flag_foreground_service = 10;
+
+ // Whether this event binds or unbinds the connection. Used to reconstruct
+ // the live service-binding graph from the event stream. DISCONNECT is a
+ // teardown that did not go through an explicit unbind (process death,
+ // cleanup); the importer treats it as a binding removal.
+ enum Action {
+ ACTION_UNKNOWN = 0;
+ ACTION_BIND = 1;
+ ACTION_UNBIND = 2;
+ ACTION_DISCONNECT = 3;
+ }
+ optional Action action = 11;
}
message AndroidProviderStateChangedEvent {
@@ -614,6 +645,20 @@
optional int32 caller_pid = 5;
// Whether the connection is stable.
optional int32 is_stable = 6;
+ // Identifier for the binding.
+ optional int32 bind_id = 7;
+
+ // Whether this event binds or unbinds the connection. Used to reconstruct
+ // the live provider-binding graph from the event stream. DISCONNECT is a
+ // teardown that did not go through an explicit unbind (process death,
+ // cleanup); the importer treats it as a binding removal.
+ enum Action {
+ ACTION_UNKNOWN = 0;
+ ACTION_BIND = 1;
+ ACTION_UNBIND = 2;
+ ACTION_DISCONNECT = 3;
+ }
+ optional Action action = 8;
}
message FrameworksBaseTrackEvent {
diff --git a/src/trace_processor/BUILD.gn b/src/trace_processor/BUILD.gn
index 9ac91de..01807bc 100644
--- a/src/trace_processor/BUILD.gn
+++ b/src/trace_processor/BUILD.gn
@@ -239,6 +239,7 @@
"plugins/perf_counter",
"plugins/perfetto_manifest",
"plugins/pprof_functions",
+ "plugins/process_state_importer",
"plugins/slice_mipmap_operator",
"plugins/span_join_operator",
"plugins/sql_stats_table",
diff --git a/src/trace_processor/importers/proto/proto_trace_parser_impl.cc b/src/trace_processor/importers/proto/proto_trace_parser_impl.cc
index be32d28..d2f7324 100644
--- a/src/trace_processor/importers/proto/proto_trace_parser_impl.cc
+++ b/src/trace_processor/importers/proto/proto_trace_parser_impl.cc
@@ -120,6 +120,23 @@
const TraceBlobView& blob = data.trace_packet_data.packet;
protos::pbzero::TracePacket::Decoder packet(blob.data(), blob.length());
module_context_->track_module->ParseTrackEventData(packet, ts, data);
+ // Also dispatch the track-event packet to any non-track modules registered
+ // for the track_event field (e.g. process_state_importer, which rebuilds
+ // its graph from process/service state-change track events). Track events are
+ // sorted and handled here, so they never reach ParseTracePacket's generic
+ // modules_by_field dispatch.
+ auto& modules = module_context_->modules_by_field;
+ uint32_t field_id = protos::pbzero::TracePacket::kTrackEventFieldNumber;
+ if (field_id < modules.size() && !modules[field_id].empty()) {
+ SelectiveTracePacketDecoder packet_fields(blob.data(), blob.length());
+ TracePacketField te_field = packet_fields.FindUnknownField(field_id);
+ for (ProtoImporterModule* module : modules[field_id]) {
+ if (module != module_context_->track_module) {
+ module->ParseField(
+ {packet_fields, ts, data.trace_packet_data, te_field});
+ }
+ }
+ }
}
void ProtoTraceParserImpl::ParseEtwEvent(uint32_t cpu,
diff --git a/src/trace_processor/plugins/process_state_importer/BUILD.gn b/src/trace_processor/plugins/process_state_importer/BUILD.gn
new file mode 100644
index 0000000..be2e2bd
--- /dev/null
+++ b/src/trace_processor/plugins/process_state_importer/BUILD.gn
@@ -0,0 +1,49 @@
+# Copyright (C) 2026 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("../../../../gn/perfetto.gni")
+import("../../../../gn/perfetto_tp_tables.gni")
+import("../../../../gn/test.gni")
+
+assert(enable_perfetto_trace_processor_sqlite)
+
+perfetto_tp_tables("tables") {
+ sources = [ "tables.py" ]
+}
+
+source_set("process_state_importer") {
+ sources = [
+ "process_state_importer.cc",
+ "process_state_importer.h",
+ "process_state_module.cc",
+ "process_state_module.h",
+ ]
+ deps = [
+ ":tables",
+ "../../../../gn:default_deps",
+ "../../../../include/perfetto/ext/base:base",
+ "../../../../protos/perfetto/trace:zero",
+ "../../../../protos/perfetto/trace/track_event:zero",
+ "../../../../protos/third_party/android/frameworks/base/proto/tracing:frameworks_base_trace_packet_zero",
+ "../../../../protos/third_party/android/frameworks/base/proto/tracing:frameworks_base_track_event_zero",
+ "../../../base",
+ "../../../protozero",
+ "../../core/plugin",
+ "../../importers/common",
+ "../../importers/common:parser_types",
+ "../../importers/proto:proto_importer_module",
+ "../../storage",
+ "../../types",
+ ]
+}
diff --git a/src/trace_processor/plugins/process_state_importer/process_state_importer.cc b/src/trace_processor/plugins/process_state_importer/process_state_importer.cc
new file mode 100644
index 0000000..02d572a
--- /dev/null
+++ b/src/trace_processor/plugins/process_state_importer/process_state_importer.cc
@@ -0,0 +1,138 @@
+/*
+ * Copyright (C) 2026 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.
+ */
+
+#include "src/trace_processor/plugins/process_state_importer/process_state_importer.h"
+
+#include <algorithm>
+#include <cstdint>
+#include <limits>
+#include <memory>
+#include <utility>
+#include <vector>
+
+#include "perfetto/base/compiler.h"
+#include "src/trace_processor/core/plugin/plugin.h"
+#include "src/trace_processor/core/plugin/registration.h"
+#include "src/trace_processor/importers/proto/proto_importer_module.h"
+#include "src/trace_processor/plugins/process_state_importer/process_state_module.h"
+#include "src/trace_processor/plugins/process_state_importer/tables_py.h"
+#include "src/trace_processor/storage/trace_storage.h"
+#include "src/trace_processor/types/trace_processor_context.h"
+
+namespace perfetto::trace_processor::process_state_importer {
+namespace {
+
+// Owns the six intrinsic tables (snapshot + process / service / provider nodes
+// and their binding edges) that ProcessStateModule fills while parsing
+// ProcessStateSnapshot packets. The tables live for the whole session.
+class ProcessStateImporter : public Plugin<ProcessStateImporter> {
+ public:
+ ~ProcessStateImporter() override;
+
+ void RegisterDataframes(std::vector<PluginDataframe>& out) override {
+ EnsureTables();
+ out.push_back({&snapshot_table_->dataframe(),
+ tables::AndroidProcessStateSnapshotTable::Name(),
+ {}});
+ out.push_back({&process_table_->dataframe(),
+ tables::AndroidProcessStateProcessTable::Name(),
+ {}});
+ out.push_back({&service_table_->dataframe(),
+ tables::AndroidProcessStateServiceTable::Name(),
+ {}});
+ out.push_back({&service_binding_table_->dataframe(),
+ tables::AndroidProcessStateServiceBindingTable::Name(),
+ {}});
+ out.push_back({&provider_table_->dataframe(),
+ tables::AndroidProcessStateProviderTable::Name(),
+ {}});
+ out.push_back({&provider_binding_table_->dataframe(),
+ tables::AndroidProcessStateProviderBindingTable::Name(),
+ {}});
+ }
+
+ void RegisterProtoImporterModules(
+ ProtoImporterModuleContext* module_context,
+ TraceProcessorContext* trace_context) override {
+ EnsureTables();
+ module_context->modules.emplace_back(new ProcessStateModule(
+ module_context, trace_context, snapshot_table_.get(),
+ process_table_.get(), service_table_.get(),
+ service_binding_table_.get(), provider_table_.get(),
+ provider_binding_table_.get()));
+ }
+
+ uint64_t GetBoundsMutationCount() override {
+ return snapshot_table_ ? snapshot_table_->mutations() : 0;
+ }
+
+ std::pair<int64_t, int64_t> GetTimestampBounds() override {
+ int64_t start_ns = std::numeric_limits<int64_t>::max();
+ int64_t end_ns = 0;
+ if (snapshot_table_) {
+ for (auto it = snapshot_table_->IterateRows(); it; ++it) {
+ start_ns = std::min(it.ts(), start_ns);
+ end_ns = std::max(it.ts(), end_ns);
+ }
+ }
+ return {start_ns, end_ns};
+ }
+
+ private:
+ void EnsureTables() {
+ if (snapshot_table_) {
+ return;
+ }
+ auto* pool = trace_context_->storage->mutable_string_pool();
+ snapshot_table_ =
+ std::make_unique<tables::AndroidProcessStateSnapshotTable>(pool);
+ process_table_ =
+ std::make_unique<tables::AndroidProcessStateProcessTable>(pool);
+ service_table_ =
+ std::make_unique<tables::AndroidProcessStateServiceTable>(pool);
+ service_binding_table_ =
+ std::make_unique<tables::AndroidProcessStateServiceBindingTable>(pool);
+ provider_table_ =
+ std::make_unique<tables::AndroidProcessStateProviderTable>(pool);
+ provider_binding_table_ =
+ std::make_unique<tables::AndroidProcessStateProviderBindingTable>(pool);
+ }
+
+ std::unique_ptr<tables::AndroidProcessStateSnapshotTable> snapshot_table_;
+ std::unique_ptr<tables::AndroidProcessStateProcessTable> process_table_;
+ std::unique_ptr<tables::AndroidProcessStateServiceTable> service_table_;
+ std::unique_ptr<tables::AndroidProcessStateServiceBindingTable>
+ service_binding_table_;
+ std::unique_ptr<tables::AndroidProcessStateProviderTable> provider_table_;
+ std::unique_ptr<tables::AndroidProcessStateProviderBindingTable>
+ provider_binding_table_;
+};
+
+ProcessStateImporter::~ProcessStateImporter() = default;
+
+} // namespace
+
+void RegisterPlugin() {
+ static PluginRegistration reg(
+ []() -> std::unique_ptr<PluginBase> {
+ return std::make_unique<ProcessStateImporter>();
+ },
+ ProcessStateImporter::kPluginId, ProcessStateImporter::kDepIds.data(),
+ ProcessStateImporter::kDepIds.size());
+ base::ignore_result(reg);
+}
+
+} // namespace perfetto::trace_processor::process_state_importer
diff --git a/src/trace_processor/plugins/process_state_importer/process_state_importer.h b/src/trace_processor/plugins/process_state_importer/process_state_importer.h
new file mode 100644
index 0000000..55c8548
--- /dev/null
+++ b/src/trace_processor/plugins/process_state_importer/process_state_importer.h
@@ -0,0 +1,26 @@
+/*
+ * Copyright (C) 2026 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.
+ */
+
+#ifndef SRC_TRACE_PROCESSOR_PLUGINS_PROCESS_STATE_IMPORTER_PROCESS_STATE_IMPORTER_H_
+#define SRC_TRACE_PROCESSOR_PLUGINS_PROCESS_STATE_IMPORTER_PROCESS_STATE_IMPORTER_H_
+
+namespace perfetto::trace_processor::process_state_importer {
+
+void RegisterPlugin();
+
+} // namespace perfetto::trace_processor::process_state_importer
+
+#endif // SRC_TRACE_PROCESSOR_PLUGINS_PROCESS_STATE_IMPORTER_PROCESS_STATE_IMPORTER_H_
diff --git a/src/trace_processor/plugins/process_state_importer/process_state_module.cc b/src/trace_processor/plugins/process_state_importer/process_state_module.cc
new file mode 100644
index 0000000..4eb0a49
--- /dev/null
+++ b/src/trace_processor/plugins/process_state_importer/process_state_module.cc
@@ -0,0 +1,418 @@
+/*
+ * Copyright (C) 2026 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.
+ */
+
+#include "src/trace_processor/plugins/process_state_importer/process_state_module.h"
+
+#include <cstdint>
+#include <string>
+
+#include "perfetto/ext/base/string_view.h"
+
+#include "src/trace_processor/plugins/process_state_importer/tables_py.h"
+#include "src/trace_processor/storage/trace_storage.h"
+#include "src/trace_processor/types/trace_processor_context.h"
+
+#include "protos/perfetto/trace/trace_packet.pbzero.h"
+#include "protos/perfetto/trace/track_event/track_event.pbzero.h"
+#include "protos/third_party/android/frameworks/base/proto/tracing/frameworks_base_trace_packet.pbzero.h"
+#include "protos/third_party/android/frameworks/base/proto/tracing/frameworks_base_track_event.pbzero.h"
+
+namespace perfetto::trace_processor {
+
+namespace fb = ::com::android::internal::pbzero;
+using ::perfetto::protos::pbzero::TracePacket;
+using ::perfetto::protos::pbzero::TrackEvent;
+
+namespace {
+
+// "PBZERO_UNKNOWN_ENUM_VALUE" is what the generated <Enum>_Name() returns for a
+// value not in the enum.
+constexpr char kUnknownEnum[] = "PBZERO_UNKNOWN_ENUM_VALUE";
+
+// Intern an enum name with its common prefix stripped (e.g.
+// "PROCESS_STATE_TOP" -> "TOP"); falls back to the raw number if unknown.
+StringId InternStripped(TraceStorage* storage,
+ const char* name,
+ size_t prefix_len,
+ int32_t raw) {
+ base::StringView sv(name);
+ if (sv == base::StringView(kUnknownEnum)) {
+ return storage->InternString(base::StringView(std::to_string(raw)));
+ }
+ if (sv.size() > prefix_len) {
+ sv = base::StringView(name + prefix_len, sv.size() - prefix_len);
+ }
+ return storage->InternString(sv);
+}
+
+} // namespace
+
+ProcessStateModule::ProcessStateModule(
+ ProtoImporterModuleContext* mc,
+ TraceProcessorContext* context,
+ tables::AndroidProcessStateSnapshotTable* snapshot_table,
+ tables::AndroidProcessStateProcessTable* process_table,
+ tables::AndroidProcessStateServiceTable* service_table,
+ tables::AndroidProcessStateServiceBindingTable* service_binding_table,
+ tables::AndroidProcessStateProviderTable* provider_table,
+ tables::AndroidProcessStateProviderBindingTable* provider_binding_table)
+ : ProtoImporterModule(mc),
+ context_(context),
+ snapshot_table_(snapshot_table),
+ process_table_(process_table),
+ service_table_(service_table),
+ service_binding_table_(service_binding_table),
+ provider_table_(provider_table),
+ provider_binding_table_(provider_binding_table) {
+ RegisterForField(
+ fb::FrameworksBaseTracePacket::kProcessStateSnapshotFieldNumber);
+ // Also receive process / service state-change track events, to rebuild the
+ // graph from the event stream (dispatched from ParseTrackEvent).
+ RegisterForField(TracePacket::kTrackEventFieldNumber);
+}
+
+ProcessStateModule::~ProcessStateModule() = default;
+
+StringId ProcessStateModule::ProcStateName(int32_t value) {
+ return InternStripped(
+ context_->storage.get(),
+ fb::ProcessStateEnum_Name(static_cast<fb::ProcessStateEnum>(value)),
+ sizeof("PROCESS_STATE_") - 1, value);
+}
+
+StringId ProcessStateModule::ReasonName(int32_t value) {
+ return InternStripped(
+ context_->storage.get(),
+ fb::OomChangeReasonEnum_Name(static_cast<fb::OomChangeReasonEnum>(value)),
+ sizeof("OOM_ADJ_REASON_") - 1, value);
+}
+
+StringId ProcessStateModule::CapabilityNames(int32_t flags) {
+ auto* storage = context_->storage.get();
+ if (flags == 0) {
+ return storage->InternString("none");
+ }
+ // Capability flag bit i corresponds to ProcessCapabilityEnum value i + 1.
+ std::string out;
+ for (int i = 0; i < 31; i++) {
+ if ((flags & (1 << i)) == 0) {
+ continue;
+ }
+ if (!out.empty()) {
+ out += " | ";
+ }
+ const char* name = fb::ProcessCapabilityEnum_Name(
+ static_cast<fb::ProcessCapabilityEnum>(i + 1));
+ if (base::StringView(name) == base::StringView(kUnknownEnum)) {
+ out += "0x" + std::to_string(1 << i);
+ } else {
+ out += (name + (sizeof("PROCESS_CAPABILITY_") - 1));
+ }
+ }
+ return storage->InternString(base::StringView(out));
+}
+
+// Writes the current running model as one snapshot, stamped at the START of the
+// oom-adj pass that produced it (its first event). The slice extends to the
+// next snapshot on the UI side, so the state stays "current" until it changes.
+void ProcessStateModule::EmitSnapshot() {
+ if (current_ts_ == -1) {
+ return;
+ }
+ tables::AndroidProcessStateSnapshotTable::Row snap_row;
+ snap_row.ts = seq_start_ts_ != -1 ? seq_start_ts_ : current_ts_;
+ if (current_reason_.has_value()) {
+ snap_row.reason = ReasonName(*current_reason_);
+ }
+ uint32_t snapshot_id = snapshot_table_->Insert(snap_row).id.value;
+
+ for (const auto& [pid, row] : processes_) {
+ auto r = row;
+ r.snapshot_id = snapshot_id;
+ process_table_->Insert(r);
+ }
+ for (const auto& [svc_id, row] : services_) {
+ auto r = row;
+ r.snapshot_id = snapshot_id;
+ service_table_->Insert(r);
+ }
+ for (const auto& [bind_id, row] : service_bindings_) {
+ auto r = row;
+ r.snapshot_id = snapshot_id;
+ service_binding_table_->Insert(r);
+ }
+ for (const auto& [provider_id, row] : providers_) {
+ auto r = row;
+ r.snapshot_id = snapshot_id;
+ provider_table_->Insert(r);
+ }
+ for (const auto& [bind_id, row] : provider_bindings_) {
+ auto r = row;
+ r.snapshot_id = snapshot_id;
+ provider_binding_table_->Insert(r);
+ }
+ graph_changed_ = false;
+}
+
+void ProcessStateModule::OnEventsFullyExtracted() {
+ if (graph_changed_) {
+ EmitSnapshot();
+ }
+}
+
+void ProcessStateModule::ParseField(const ParseFieldArgs& args) {
+ const int64_t ts = args.ts;
+ auto* storage = context_->storage.get();
+
+ // Path 2: reconstruct the graph from process / service state-change track
+ // events. We keep a running model and emit a snapshot whenever the oom-adj
+ // sequence id advances.
+ if (args.field.id() == TracePacket::kTrackEventFieldNumber) {
+ TrackEvent::Decoder track_event(
+ args.field.Cast<TracePacket::kTrackEvent>());
+
+ auto process_state_field = track_event.FindField(
+ fb::FrameworksBaseTrackEvent::kProcessStateChangedEventFieldNumber);
+ if (process_state_field.valid()) {
+ fb::AndroidProcessStateChangedEvent::Decoder p(
+ process_state_field.as_bytes());
+ // A new oom-adj sequence id ends the previous adjuster pass: flush the
+ // graph as it stood at that point before applying this pass's changes.
+ if (p.has_seq_id() && p.seq_id() != current_seq_id_) {
+ if (current_seq_id_ != -1 && graph_changed_) {
+ EmitSnapshot();
+ }
+ current_seq_id_ = p.seq_id();
+ seq_start_ts_ = ts; // first event of the new oom-adj pass
+ }
+ current_ts_ = ts;
+ // Capture the pass reason (after any flush above, so the just-emitted
+ // snapshot kept the previous pass's reason). All events in a pass share
+ // it.
+ if (p.has_reason()) {
+ current_reason_ = static_cast<int32_t>(p.reason());
+ }
+
+ int32_t pid = p.pid();
+ auto& row = processes_[pid];
+ row.pid = pid;
+ if (p.has_uid()) {
+ row.uid = p.uid();
+ }
+ row.oom_score = p.cur_oom_score();
+ if (p.has_cur_proc_state()) {
+ row.proc_state =
+ ProcStateName(static_cast<int32_t>(p.cur_proc_state()));
+ }
+ if (p.has_cur_capability_flags()) {
+ row.capabilities = CapabilityNames(p.cur_capability_flags());
+ }
+ graph_changed_ = true;
+ }
+
+ auto service_state_field = track_event.FindField(
+ fb::FrameworksBaseTrackEvent::kServiceStateChangedEventFieldNumber);
+ if (service_state_field.valid()) {
+ fb::AndroidServiceStateChangedEvent::Decoder s(
+ service_state_field.as_bytes());
+ current_ts_ = ts;
+ if (s.has_action()) {
+ if (s.action() == fb::AndroidServiceStateChangedEvent::ACTION_BIND) {
+ // Intern the service by (host uid, component) so repeated binds to
+ // the same service reuse one synthetic id.
+ auto key = std::make_pair(
+ s.uid(),
+ storage->InternString(s.component_name().ToStdStringView()));
+ int32_t svc_id;
+ auto found = service_to_id_.find(key);
+ if (found == service_to_id_.end()) {
+ svc_id = next_svc_id_++;
+ service_to_id_[key] = svc_id;
+ tables::AndroidProcessStateServiceTable::Row s_row;
+ s_row.svc_id = svc_id;
+ s_row.owning_pid = s.pid();
+ s_row.name = key.second;
+ services_[svc_id] = s_row;
+ } else {
+ svc_id = found->second;
+ }
+
+ tables::AndroidProcessStateServiceBindingTable::Row b_row;
+ b_row.client_pid = s.caller_pid();
+ b_row.service_id = svc_id;
+ if (s.has_flag_foreground_service()) {
+ b_row.foreground = s.flag_foreground_service() ? 1 : 0;
+ }
+ service_bindings_[s.bind_id()] = b_row;
+ graph_changed_ = true;
+ } else if (s.action() ==
+ fb::AndroidServiceStateChangedEvent::ACTION_UNBIND ||
+ s.action() ==
+ fb::AndroidServiceStateChangedEvent::ACTION_DISCONNECT) {
+ // The connection is gone (explicit unbind or teardown), drop the
+ // edge.
+ service_bindings_.erase(s.bind_id());
+ graph_changed_ = true;
+ }
+ }
+ }
+
+ auto provider_state_field = track_event.FindField(
+ fb::FrameworksBaseTrackEvent::kProviderStateChangedEventFieldNumber);
+ if (provider_state_field.valid()) {
+ fb::AndroidProviderStateChangedEvent::Decoder pv(
+ provider_state_field.as_bytes());
+ current_ts_ = ts;
+ if (pv.has_action()) {
+ if (pv.action() == fb::AndroidProviderStateChangedEvent::ACTION_BIND) {
+ // Intern the provider by (host uid, authority) so repeated binds to
+ // the same provider reuse one synthetic id.
+ auto key = std::make_pair(
+ pv.uid(),
+ storage->InternString(pv.authority().ToStdStringView()));
+ int32_t provider_id;
+ auto found = provider_to_id_.find(key);
+ if (found == provider_to_id_.end()) {
+ provider_id = next_provider_id_++;
+ provider_to_id_[key] = provider_id;
+ tables::AndroidProcessStateProviderTable::Row p_row;
+ p_row.provider_id = provider_id;
+ p_row.owning_pid = pv.pid();
+ p_row.authority = key.second;
+ providers_[provider_id] = p_row;
+ } else {
+ provider_id = found->second;
+ }
+
+ tables::AndroidProcessStateProviderBindingTable::Row b_row;
+ b_row.client_pid = pv.caller_pid();
+ b_row.provider_id = provider_id;
+ if (pv.has_is_stable()) {
+ b_row.stable = pv.is_stable() ? 1 : 0;
+ }
+ provider_bindings_[pv.bind_id()] = b_row;
+ graph_changed_ = true;
+ } else if (pv.action() ==
+ fb::AndroidProviderStateChangedEvent::ACTION_UNBIND ||
+ pv.action() == fb::AndroidProviderStateChangedEvent::
+ ACTION_DISCONNECT) {
+ // The connection is gone (explicit unbind or teardown), drop the
+ // edge.
+ provider_bindings_.erase(pv.bind_id());
+ graph_changed_ = true;
+ }
+ }
+ }
+ return;
+ }
+
+ // Path 1: a one-shot ProcessStateSnapshot (dumpsys activity). Seed the
+ // running model from it and emit immediately, sharing EmitSnapshot.
+ if (args.field.id() !=
+ fb::FrameworksBaseTracePacket::kProcessStateSnapshotFieldNumber) {
+ return;
+ }
+ fb::ProcessStateSnapshot::Decoder snapshot(
+ args.field.Cast<fb::FrameworksBaseTracePacket::kProcessStateSnapshot>());
+
+ current_ts_ = ts;
+ seq_start_ts_ = ts; // a one-shot snapshot is a point (dur 0)
+ current_seq_id_ = -1;
+ current_reason_.reset(); // a one-shot snapshot has no oom-adj pass reason
+ processes_.clear();
+ services_.clear();
+ service_to_id_.clear();
+ service_bindings_.clear();
+ providers_.clear();
+ provider_to_id_.clear();
+ provider_bindings_.clear();
+
+ for (auto it = snapshot.processes(); it; ++it) {
+ fb::ProcessStateSnapshot::Process::Decoder p(*it);
+ tables::AndroidProcessStateProcessTable::Row row;
+ row.pid = p.pid();
+ row.uid = p.uid();
+ row.name = storage->InternString(p.name().ToStdStringView());
+ // oom_score 0 (FOREGROUND_APP_ADJ) is meaningful; ProtoOutputStream omits a
+ // 0-valued singleton, so populate it unconditionally rather than NULL.
+ row.oom_score = p.oom_score();
+ if (p.has_proc_state()) {
+ row.proc_state = ProcStateName(p.proc_state());
+ }
+ if (p.has_capability_flags()) {
+ row.capabilities = CapabilityNames(p.capability_flags());
+ }
+ if (p.has_persistent()) {
+ row.persistent = p.persistent() ? 1 : 0;
+ }
+ processes_[row.pid] = row;
+ }
+
+ for (auto it = snapshot.services(); it; ++it) {
+ fb::ProcessStateSnapshot::Service::Decoder s(*it);
+ tables::AndroidProcessStateServiceTable::Row row;
+ row.svc_id = s.id();
+ if (s.has_owning_pid()) {
+ row.owning_pid = s.owning_pid();
+ }
+ if (s.has_name()) {
+ row.name = storage->InternString(s.name().ToStdStringView());
+ }
+ services_[row.svc_id] = row;
+ }
+
+ int32_t synth = 0;
+ for (auto it = snapshot.service_bindings(); it; ++it) {
+ fb::ProcessStateSnapshot::ServiceBinding::Decoder b(*it);
+ tables::AndroidProcessStateServiceBindingTable::Row row;
+ row.client_pid = b.client_pid();
+ row.service_id = b.service_id();
+ if (b.has_foreground()) {
+ row.foreground = b.foreground() ? 1 : 0;
+ }
+ service_bindings_[synth++] = row;
+ }
+
+ for (auto it = snapshot.providers(); it; ++it) {
+ fb::ProcessStateSnapshot::Provider::Decoder p(*it);
+ tables::AndroidProcessStateProviderTable::Row row;
+ row.provider_id = p.id();
+ if (p.has_owning_pid()) {
+ row.owning_pid = p.owning_pid();
+ }
+ if (p.has_authority()) {
+ row.authority = storage->InternString(p.authority().ToStdStringView());
+ }
+ providers_[row.provider_id] = row;
+ }
+
+ synth = 0;
+ for (auto it = snapshot.provider_bindings(); it; ++it) {
+ fb::ProcessStateSnapshot::ProviderBinding::Decoder b(*it);
+ tables::AndroidProcessStateProviderBindingTable::Row row;
+ row.client_pid = b.client_pid();
+ row.provider_id = b.provider_id();
+ if (b.has_stable()) {
+ row.stable = b.stable() ? 1 : 0;
+ }
+ provider_bindings_[synth++] = row;
+ }
+
+ graph_changed_ = true;
+ EmitSnapshot();
+}
+
+} // namespace perfetto::trace_processor
diff --git a/src/trace_processor/plugins/process_state_importer/process_state_module.h b/src/trace_processor/plugins/process_state_importer/process_state_module.h
new file mode 100644
index 0000000..14b0eba
--- /dev/null
+++ b/src/trace_processor/plugins/process_state_importer/process_state_module.h
@@ -0,0 +1,114 @@
+/*
+ * Copyright (C) 2026 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.
+ */
+
+#ifndef SRC_TRACE_PROCESSOR_PLUGINS_PROCESS_STATE_IMPORTER_PROCESS_STATE_MODULE_H_
+#define SRC_TRACE_PROCESSOR_PLUGINS_PROCESS_STATE_IMPORTER_PROCESS_STATE_MODULE_H_
+
+#include <cstdint>
+#include <map>
+#include <optional>
+#include <utility>
+
+#include "src/trace_processor/importers/common/parser_types.h"
+#include "src/trace_processor/importers/proto/proto_importer_module.h"
+#include "src/trace_processor/plugins/process_state_importer/tables_py.h"
+#include "src/trace_processor/storage/trace_storage.h"
+
+namespace perfetto::trace_processor {
+
+class TraceProcessorContext;
+
+// Populates the plugin-owned intrinsic tables (snapshot + process / service /
+// provider nodes and their binding edges) two ways:
+// 1. From a one-shot ProcessStateSnapshot packet (dumpsys activity): one
+// snapshot row plus all its processes / services / providers / bindings.
+// 2. By reconstructing the graph from the Android{Process,Service,Provider}-
+// StateChangedEvent track-event stream: keeps a running model and emits a
+// new snapshot whenever the oom-adj sequence id advances (and a final one
+// at end of trace). Service and provider bindings are tracked by their
+// bind/unbind action + bind_id.
+// Enum-valued fields are resolved to their names here (via the generated
+// <Enum>_Name() helpers) so the UI keeps no enum tables of its own.
+class ProcessStateModule : public ProtoImporterModule {
+ public:
+ ProcessStateModule(
+ ProtoImporterModuleContext* module_context,
+ TraceProcessorContext* context,
+ tables::AndroidProcessStateSnapshotTable* snapshot_table,
+ tables::AndroidProcessStateProcessTable* process_table,
+ tables::AndroidProcessStateServiceTable* service_table,
+ tables::AndroidProcessStateServiceBindingTable* service_binding_table,
+ tables::AndroidProcessStateProviderTable* provider_table,
+ tables::AndroidProcessStateProviderBindingTable* provider_binding_table);
+ ~ProcessStateModule() override;
+
+ void ParseField(const ParseFieldArgs& args) override;
+
+ // Flushes the last reconstructed graph at end of trace.
+ void OnEventsFullyExtracted() override;
+
+ private:
+ // Writes the current running model as a new snapshot at current_ts_.
+ void EmitSnapshot();
+
+ // Resolve enum values to interned display names via the generated
+ // <Enum>_Name() helpers (the prefix is stripped for brevity).
+ StringId ProcStateName(int32_t value);
+ StringId ReasonName(int32_t value);
+ // A " | "-joined list of the granted ProcessCapabilityEnum names ("none" if
+ // the bitmask is zero).
+ StringId CapabilityNames(int32_t flags);
+
+ TraceProcessorContext* const context_;
+ tables::AndroidProcessStateSnapshotTable* const snapshot_table_;
+ tables::AndroidProcessStateProcessTable* const process_table_;
+ tables::AndroidProcessStateServiceTable* const service_table_;
+ tables::AndroidProcessStateServiceBindingTable* const service_binding_table_;
+ tables::AndroidProcessStateProviderTable* const provider_table_;
+ tables::AndroidProcessStateProviderBindingTable* const
+ provider_binding_table_;
+
+ // Running model of the graph, rebuilt from the track-event stream (and seeded
+ // wholesale by a one-shot snapshot). Keyed so repeated events update in
+ // place.
+ std::map<int32_t, tables::AndroidProcessStateProcessTable::Row> processes_;
+ std::map<int32_t, tables::AndroidProcessStateServiceTable::Row> services_;
+ std::map<std::pair<int32_t, StringId>, int32_t> service_to_id_;
+ std::map<int32_t, tables::AndroidProcessStateServiceBindingTable::Row>
+ service_bindings_;
+ std::map<int32_t, tables::AndroidProcessStateProviderTable::Row> providers_;
+ std::map<std::pair<int32_t, StringId>, int32_t> provider_to_id_;
+ std::map<int32_t, tables::AndroidProcessStateProviderBindingTable::Row>
+ provider_bindings_;
+
+ int32_t next_svc_id_ = 10000;
+ int32_t next_provider_id_ = 20000;
+ int64_t current_seq_id_ = -1;
+ // A snapshot is stamped at the START of its oom-adj pass (the first event
+ // carrying this seq_id); seq_start_ts_ tracks that. current_ts_ is the
+ // running last-seen event ts.
+ int64_t seq_start_ts_ = -1;
+ int64_t current_ts_ = -1;
+ // OomChangeReasonEnum of the oom-adj pass currently being accumulated;
+ // written onto the snapshot when emitted. Unset for a one-shot dumpsys
+ // snapshot.
+ std::optional<int32_t> current_reason_;
+ bool graph_changed_ = false;
+};
+
+} // namespace perfetto::trace_processor
+
+#endif // SRC_TRACE_PROCESSOR_PLUGINS_PROCESS_STATE_IMPORTER_PROCESS_STATE_MODULE_H_
diff --git a/src/trace_processor/plugins/process_state_importer/tables.py b/src/trace_processor/plugins/process_state_importer/tables.py
new file mode 100644
index 0000000..f6a14c3
--- /dev/null
+++ b/src/trace_processor/plugins/process_state_importer/tables.py
@@ -0,0 +1,186 @@
+# Copyright (C) 2026 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 python.generators.trace_processor_table.public import Column as C
+from python.generators.trace_processor_table.public import CppAccess
+from python.generators.trace_processor_table.public import CppAccessDuration
+from python.generators.trace_processor_table.public import CppInt32
+from python.generators.trace_processor_table.public import CppInt64
+from python.generators.trace_processor_table.public import CppOptional
+from python.generators.trace_processor_table.public import CppString
+from python.generators.trace_processor_table.public import CppUint32
+from python.generators.trace_processor_table.public import Table
+from python.generators.trace_processor_table.public import TableDoc
+
+# One row per ProcessStateSnapshot packet (a `dumpsys activity` snapshot, or one
+# reconstructed from the oom-adj event stream). The process / service / provider
+# tables below reference it by snapshot_id (its row id). Enum-valued fields are
+# stored as their resolved names (via the generated <Enum>_Name() helpers), so
+# the UI needs no enum tables of its own.
+SNAPSHOT_TABLE = Table(
+ python_module=__file__,
+ class_name='AndroidProcessStateSnapshotTable',
+ sql_name='__intrinsic_android_process_state_snapshot',
+ columns=[
+ C('ts',
+ CppInt64(),
+ cpp_access=CppAccess.READ,
+ cpp_access_duration=CppAccessDuration.POST_FINALIZATION),
+ C('reason', CppOptional(CppString())),
+ ],
+ tabledoc=TableDoc(
+ doc='''
+ A point-in-time process / service / provider importance graph snapshot
+ (the data behind `dumpsys activity`).
+ ''',
+ group='Android',
+ columns={
+ 'ts': 'Start timestamp (first event of the oom-adj pass; the '
+ 'dumpsys capture time for a one-shot snapshot). The snapshot '
+ 'is current until the next snapshot.',
+ 'reason':
+ 'OomChangeReasonEnum name of the oom-adj pass that '
+ 'produced this snapshot when reconstructed from the event '
+ 'stream; NULL for a one-shot dumpsys snapshot.',
+ }))
+
+# Processes in a snapshot (the nodes of the graph).
+PROCESS_TABLE = Table(
+ python_module=__file__,
+ class_name='AndroidProcessStateProcessTable',
+ sql_name='__intrinsic_android_process_state_process',
+ columns=[
+ C('snapshot_id', CppUint32()),
+ C('pid', CppInt32()),
+ C('uid', CppInt32()),
+ C('name', CppString()),
+ C('oom_score', CppOptional(CppInt32())),
+ C('proc_state', CppOptional(CppString())),
+ C('capabilities', CppOptional(CppString())),
+ C('persistent', CppOptional(CppInt32())),
+ ],
+ tabledoc=TableDoc(
+ doc='A process present in a process-state snapshot.',
+ group='Android',
+ columns={
+ 'snapshot_id': 'The snapshot row id.',
+ 'pid': 'Process id.',
+ 'uid': 'Process uid.',
+ 'name': 'Process name.',
+ 'oom_score': 'oom_adj score (lower = more important).',
+ 'proc_state': 'ProcessStateEnum name (e.g. "BOUND_FOREGROUND_'
+ 'SERVICE").',
+ 'capabilities': 'ProcessCapabilityEnum names granted, " | "-joined '
+ '("none" if zero).',
+ 'persistent': '1 if a persistent process.',
+ }))
+
+# Services in a snapshot, referenced by service bindings via svc_id.
+SERVICE_TABLE = Table(
+ python_module=__file__,
+ class_name='AndroidProcessStateServiceTable',
+ sql_name='__intrinsic_android_process_state_service',
+ columns=[
+ C('snapshot_id', CppUint32()),
+ C('svc_id', CppInt32()),
+ C('owning_pid', CppOptional(CppInt32())),
+ C('name', CppOptional(CppString())),
+ ],
+ tabledoc=TableDoc(
+ doc='A service present in a process-state snapshot.',
+ group='Android',
+ columns={
+ 'snapshot_id': 'The snapshot row id.',
+ 'svc_id': 'Per-snapshot service id, referenced by '
+ '__intrinsic_android_process_state_service_binding.'
+ 'service_id.',
+ 'owning_pid': 'Pid of the process hosting the service.',
+ 'name': 'Short service / component name.',
+ }))
+
+# Client -> service bindings in a snapshot (edges of the graph).
+SERVICE_BINDING_TABLE = Table(
+ python_module=__file__,
+ class_name='AndroidProcessStateServiceBindingTable',
+ sql_name='__intrinsic_android_process_state_service_binding',
+ columns=[
+ C('snapshot_id', CppUint32()),
+ C('client_pid', CppInt32()),
+ C('service_id', CppInt32()),
+ C('foreground', CppOptional(CppInt32())),
+ ],
+ tabledoc=TableDoc(
+ doc='A client->service binding in a process-state snapshot.',
+ group='Android',
+ columns={
+ 'snapshot_id': 'The snapshot row id.',
+ 'client_pid': 'Pid of the binding client.',
+ 'service_id': 'The bound service svc_id (same snapshot).',
+ 'foreground': '1 if bound with BIND_FOREGROUND_SERVICE.',
+ }))
+
+# Content providers in a snapshot, referenced by provider bindings.
+PROVIDER_TABLE = Table(
+ python_module=__file__,
+ class_name='AndroidProcessStateProviderTable',
+ sql_name='__intrinsic_android_process_state_provider',
+ columns=[
+ C('snapshot_id', CppUint32()),
+ C('provider_id', CppInt32()),
+ C('owning_pid', CppOptional(CppInt32())),
+ C('authority', CppOptional(CppString())),
+ ],
+ tabledoc=TableDoc(
+ doc='A content provider present in a process-state snapshot.',
+ group='Android',
+ columns={
+ 'snapshot_id': 'The snapshot row id.',
+ 'provider_id': 'Per-snapshot provider id, referenced by '
+ '__intrinsic_android_process_state_provider_binding.'
+ 'provider_id.',
+ 'owning_pid': 'Pid of the process hosting the provider.',
+ 'authority': 'Content provider authority.',
+ }))
+
+# Client -> provider bindings in a snapshot (edges of the graph).
+PROVIDER_BINDING_TABLE = Table(
+ python_module=__file__,
+ class_name='AndroidProcessStateProviderBindingTable',
+ sql_name='__intrinsic_android_process_state_provider_binding',
+ columns=[
+ C('snapshot_id', CppUint32()),
+ C('client_pid', CppInt32()),
+ C('provider_id', CppInt32()),
+ C('stable', CppOptional(CppInt32())),
+ ],
+ tabledoc=TableDoc(
+ doc='A client->provider binding in a process-state snapshot.',
+ group='Android',
+ columns={
+ 'snapshot_id': 'The snapshot row id.',
+ 'client_pid': 'Pid of the binding client.',
+ 'provider_id': 'The referenced provider provider_id (same '
+ 'snapshot).',
+ 'stable': '1 if a stable provider connection.',
+ }))
+
+# Keep this list sorted.
+ALL_TABLES = [
+ PROCESS_TABLE,
+ PROVIDER_BINDING_TABLE,
+ PROVIDER_TABLE,
+ SERVICE_BINDING_TABLE,
+ SERVICE_TABLE,
+ SNAPSHOT_TABLE,
+]
diff --git a/src/trace_processor/trace_processor_impl.cc b/src/trace_processor/trace_processor_impl.cc
index 1c7778d..3e01743 100644
--- a/src/trace_processor/trace_processor_impl.cc
+++ b/src/trace_processor/trace_processor_impl.cc
@@ -120,6 +120,7 @@
#include "src/trace_processor/plugins/perf_counter/perf_counter.h"
#include "src/trace_processor/plugins/perfetto_manifest/perfetto_manifest.h"
#include "src/trace_processor/plugins/pprof_functions/pprof_functions.h"
+#include "src/trace_processor/plugins/process_state_importer/process_state_importer.h"
#include "src/trace_processor/plugins/slice_mipmap_operator/slice_mipmap_operator.h"
#include "src/trace_processor/plugins/span_join_operator/span_join_operator.h"
#include "src/trace_processor/plugins/sql_stats_table/sql_stats_table.h"
@@ -318,6 +319,7 @@
// issues, so instead each plugin exposes an explicit Register* function that
// we call here before GetPluginSet() builds its cached set. Remove these
// explicit calls once the static-init based registration is restored.
+ process_state_importer::RegisterPlugin();
ancestor::RegisterPlugin();
android_framework_track_event::RegisterPlugin();
args::RegisterPlugin();
diff --git a/ui/src/components/widgets/charts_svg/layered_graph.scss b/ui/src/components/widgets/charts_svg/layered_graph.scss
new file mode 100644
index 0000000..5c2c64a
--- /dev/null
+++ b/ui/src/components/widgets/charts_svg/layered_graph.scss
@@ -0,0 +1,143 @@
+// Copyright (C) 2026 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.
+
+// Self-contained styles for the LayeredGraph SVG chart widget (so it can be
+// reused without pulling in any host plugin's CSS).
+.pf-lgraph-graph {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ flex: 1 1 auto;
+ min-width: 0;
+ width: 100%;
+ overflow: hidden;
+ position: relative;
+ .pf-lgraph-legend {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ font-size: 11px;
+ padding: 3px 10px;
+ color: var(--pf-color-text-muted, #888);
+ .pf-lgraph-grow {
+ flex: 1;
+ }
+ .pf-lgraph-fit {
+ font-size: 11px;
+ padding: 1px 8px;
+ cursor: pointer;
+ border: 1px solid var(--pf-color-border, #ccc);
+ background: transparent;
+ border-radius: 4px;
+ }
+ }
+ .pf-lgraph-svg {
+ flex: 1;
+ min-height: 0;
+ cursor: grab;
+ touch-action: none;
+ user-select: none;
+ &:active {
+ cursor: grabbing;
+ }
+ // Faint tint so the graph reads as a distinct, pannable surface.
+ background-color: color-mix(
+ in srgb,
+ var(--pf-color-background) 94%,
+ var(--pf-color-border-secondary) 6%
+ );
+ }
+ .pf-lgraph-edge {
+ cursor: pointer;
+ }
+ .pf-lgraph-node {
+ cursor: pointer;
+ }
+ // Node ring is theme-aware: a background-coloured halo at rest, the text
+ // colour on hover/selection (contrasts in both light and dark mode).
+ .pf-lgraph-node circle {
+ stroke: var(--pf-color-background);
+ stroke-width: 0.6;
+ }
+ .pf-lgraph-node:hover circle {
+ stroke: var(--pf-color-text);
+ stroke-width: 1.5;
+ }
+ .pf-lgraph-node--sel circle {
+ stroke: var(--pf-color-text);
+ stroke-width: 2;
+ }
+ // Diff mode: added/changed get a coloured ring; removed are dashed, muted
+ // ghosts. Declared after the base/hover/sel rules so they win on equal
+ // specificity.
+ .pf-lgraph-node.pf-lgraph-diff-added circle {
+ stroke: #43a047;
+ stroke-width: 2.5;
+ }
+ .pf-lgraph-node.pf-lgraph-diff-changed circle {
+ stroke: #fb8c00;
+ stroke-width: 2.5;
+ }
+ .pf-lgraph-node.pf-lgraph-diff-removed {
+ opacity: 0.45;
+ }
+ .pf-lgraph-node.pf-lgraph-diff-removed circle {
+ stroke: #e53935;
+ stroke-dasharray: 3 2;
+ }
+ text {
+ fill: var(--pf-color-text, #222);
+ }
+ .pf-lgraph-col {
+ fill: var(--pf-color-text-muted, #666);
+ }
+ .pf-lgraph-nlabel {
+ fill: var(--pf-color-text, #222);
+ stroke: var(--pf-color-background, #fff);
+ stroke-width: 2.6px;
+ paint-order: stroke;
+ stroke-linejoin: round;
+ font-family: var(--pf-font-monospace, monospace);
+ }
+}
+
+// hover tooltip (rendered into the Perfetto CursorTooltip portal).
+.pf-lgraph-tip {
+ max-width: 300px;
+ overflow-wrap: anywhere;
+ .pf-lgraph-tip-name {
+ font-size: 12px;
+ font-family: var(--pf-font-monospace, monospace);
+ }
+ .pf-lgraph-tip-sub {
+ font-size: 11px;
+ margin-top: 2px;
+ color: var(--pf-color-text-muted, #888);
+ }
+}
+
+// Legend diff swatches (caller passes the text; these colour it).
+.pf-lgraph-diff-leg {
+ font-size: 11px;
+ margin-left: 10px;
+}
+.pf-lgraph-diff-added {
+ color: #2e7d32;
+}
+.pf-lgraph-diff-changed {
+ color: #ef6c00;
+}
+.pf-lgraph-diff-removed {
+ color: #c62828;
+}
diff --git a/ui/src/components/widgets/charts_svg/layered_graph.ts b/ui/src/components/widgets/charts_svg/layered_graph.ts
new file mode 100644
index 0000000..9e8c817
--- /dev/null
+++ b/ui/src/components/widgets/charts_svg/layered_graph.ts
@@ -0,0 +1,501 @@
+// Copyright (C) 2026 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 m from 'mithril';
+import {ensureExists} from '../../../base/assert';
+import './layered_graph.scss';
+import {CursorTooltip} from '../../../widgets/cursor_tooltip';
+
+// A generic, data-driven SVG node-graph chart, following the same
+// data-in / callbacks-out convention as the other charts_svg widgets: a
+// ClassComponent with a `readonly` Attrs interface, fed `nodes` / `edges` /
+// `layerLabels` (like DataGrid is fed `data` / `schema`) and reporting selection
+// back through callbacks. Nodes are laid out left-to-right in ordered "layers"
+// (columns) with directed edges between them. It owns only presentation +
+// interaction (deterministic layout, zoom / pan, hover, select, degree-based
+// node sizing, edges-revealed-on-focus, diff styling), has no knowledge of what
+// the nodes are, and is self-contained (its styles live in layered_graph.scss),
+// so it can be reused outside any one plugin.
+
+// Diff status of a node/edge vs a baseline. Absent = unchanged (no styling).
+export type GraphDiff = 'added' | 'removed' | 'changed';
+
+export interface GraphNode {
+ readonly id: number;
+ // Short display name; the widget truncates it and appends the id.
+ readonly label: string;
+ readonly layer: number; // column index into layerLabels (the layer)
+ readonly fill: string; // circle fill colour
+ readonly diff?: GraphDiff;
+ readonly tooltip?: m.Children; // hover tooltip body (full name, etc.)
+}
+
+export interface GraphEdge {
+ readonly id: string; // stable identity for selection / hover
+ readonly from: number; // source node id
+ readonly to: number; // target node id
+ readonly color: string;
+ readonly dashed?: boolean;
+ readonly diff?: 'added' | 'removed'; // drawn prominently at rest in diff mode
+ readonly tooltip?: m.Children;
+}
+
+export interface LayeredGraphAttrs {
+ readonly nodes: ReadonlyArray<GraphNode>;
+ readonly edges: ReadonlyArray<GraphEdge>;
+ readonly layerLabels: ReadonlyArray<string>; // column headers, left→right
+ readonly selectedIds?: ReadonlySet<number>;
+ readonly selectedEdgeId?: string;
+ // Legend content shown left of the Fit button (caller-specific swatches).
+ readonly legend?: m.Children;
+ // additive (shift-click) asks the caller to toggle the id in a multi-select.
+ readonly onSelect?: (id: number, additive: boolean) => void;
+ readonly onEdgeSelect?: (edgeId: string) => void;
+ readonly onDeselect?: () => void;
+}
+
+export class LayeredGraph implements m.ClassComponent<LayeredGraphAttrs> {
+ private hoverId?: number;
+ private hoverEdgeId?: string;
+ // CursorTooltip reads a module-level mouse position only set after the first
+ // document mousemove; a mouseenter under a stationary cursor before any move
+ // crashes it. Gate on having actually seen pointer movement over the graph.
+ private sawPointer = false;
+ // viewBox for zoom/pan. While userAdjusted is false the graph auto-fits to
+ // content on every render; once the user zooms/pans we preserve their
+ // viewport. Fit resets userAdjusted.
+ private vb?: {x: number; y: number; w: number; h: number};
+ private userAdjusted = false;
+ private vw = 0; // svg viewport px (for aspect-matched fit)
+ private vh = 0;
+ private cw = 0; // content bounds (for pan clamping)
+ private ch = 0;
+ // Pending pointer-down (not yet a drag) + whether we crossed the threshold.
+ // We only capture the pointer once a real drag starts, so plain clicks still
+ // reach nodes/edges.
+ private down?: {
+ px: number;
+ py: number;
+ vx: number;
+ vy: number;
+ id: number;
+ el: Element;
+ };
+ private dragging = false;
+ // True once a real pan happens, so the click that ends a drag isn't treated
+ // as an empty-space click (which would clear the selection).
+ private didDrag = false;
+
+ private onWheel(ev: WheelEvent) {
+ if (!this.vb) return;
+ this.userAdjusted = true;
+ ev.preventDefault();
+ const svg = ev.currentTarget as SVGSVGElement;
+ const rect = svg.getBoundingClientRect();
+ const fx = (ev.clientX - rect.left) / rect.width;
+ const fy = (ev.clientY - rect.top) / rect.height;
+ const k = ev.deltaY > 0 ? 1.12 : 1 / 1.12; // wheel down = zoom out
+ // Allow deep zoom-in (min viewBox 20px) so edges that bunch together can be
+ // spread far enough apart to click individually.
+ const nw = Math.max(20, Math.min(this.vb.w * 8, this.vb.w * k));
+ const nh = this.vb.h * (nw / this.vb.w);
+ this.vb = {
+ x: this.vb.x + (this.vb.w - nw) * fx,
+ y: this.vb.y + (this.vb.h - nh) * fy,
+ w: nw,
+ h: nh,
+ };
+ }
+ private onDown(ev: PointerEvent) {
+ if (!this.vb) return;
+ this.down = {
+ px: ev.clientX,
+ py: ev.clientY,
+ vx: this.vb.x,
+ vy: this.vb.y,
+ id: ev.pointerId,
+ el: ev.currentTarget as Element,
+ };
+ this.dragging = false;
+ }
+ private onMove(ev: PointerEvent) {
+ this.sawPointer = true;
+ if (!this.down || !this.vb) return;
+ const dx = ev.clientX - this.down.px;
+ const dy = ev.clientY - this.down.py;
+ if (!this.dragging) {
+ if (Math.hypot(dx, dy) < 4) return; // still a click, not a drag
+ this.dragging = true;
+ this.didDrag = true;
+ this.userAdjusted = true;
+ this.down.el.setPointerCapture(this.down.id);
+ }
+ const rect = (ev.currentTarget as Element).getBoundingClientRect();
+ let nx = this.down.vx - (dx / rect.width) * this.vb.w;
+ let ny = this.down.vy - (dy / rect.height) * this.vb.h;
+ const M = 60; // keep at least a margin of content on screen
+ nx = Math.max(-this.vb.w + M, Math.min(this.cw - M, nx));
+ ny = Math.max(-this.vb.h + M, Math.min(this.ch - M, ny));
+ this.vb.x = nx;
+ this.vb.y = ny;
+ }
+ private onUp() {
+ this.down = undefined;
+ this.dragging = false;
+ }
+
+ view({attrs}: m.Vnode<LayeredGraphAttrs>) {
+ const colW = 250;
+ const labelDx = 9;
+ const rowH = 26;
+ const top = 34;
+ const left = 24;
+ const r = 5;
+
+ // Lay nodes out in their layer columns, left→right. Empty layers are
+ // skipped so the layout stays compact (no blank gutter columns).
+ const nLayers = attrs.layerLabels.length;
+ const cols: number[][] = Array.from({length: nLayers}, () => []);
+ const byId = new Map<number, GraphNode>();
+ for (const n of attrs.nodes) {
+ const b = Math.max(0, Math.min(nLayers - 1, n.layer));
+ cols[b].push(n.id);
+ byId.set(n.id, n);
+ }
+ // Connection count (in + out edges) per node; drives node size below and
+ // the in-column ordering here.
+ const degree = new Map<number, number>();
+ for (const e of attrs.edges) {
+ degree.set(e.from, (degree.get(e.from) ?? 0) + 1);
+ degree.set(e.to, (degree.get(e.to) ?? 0) + 1);
+ }
+ // Within each layer, order nodes by size (degree) descending so the biggest
+ // dots sit at the top of the column; tie-break by id for a stable layout.
+ for (const col of cols) {
+ col.sort((a, b) => (degree.get(b) ?? 0) - (degree.get(a) ?? 0) || a - b);
+ }
+
+ const activeLayers = cols
+ .map((_, i) => i)
+ .filter((i) => cols[i].length > 0);
+ const pos = new Map<number, {x: number; y: number}>();
+ activeLayers.forEach((b, ci) => {
+ cols[b].forEach((id, row) => {
+ pos.set(id, {x: left + ci * colW, y: top + row * rowH});
+ });
+ });
+ const height = top + 10 + Math.max(...cols.map((c) => c.length), 1) * rowH;
+ const width = left + Math.max(1, activeLayers.length) * colW;
+
+ // Node size = connection count (in + out edges): hubs read as big dots the
+ // moment the graph opens, so the overall shape is visible without drawing
+ // any edges. `degree` is computed above (it also orders each column).
+ const radiusOf = (id: number) =>
+ r + Math.min(8, Math.sqrt(degree.get(id) ?? 0) * 1.7);
+
+ const edgesById = new Map<string, GraphEdge>(
+ attrs.edges.map((e) => [e.id, e]),
+ );
+ const sel = attrs.selectedIds;
+ const selEdge =
+ attrs.selectedEdgeId !== undefined
+ ? edgesById.get(attrs.selectedEdgeId)
+ : undefined;
+ // Edges are hidden by default (so labels are never crossed) and revealed
+ // only for the focused node(s): the hovered node (transient), or the
+ // selected node(s) (pinned bold). A selected edge keeps its client end's
+ // edges on screen too.
+ const focusIds =
+ this.hoverId !== undefined
+ ? new Set<number>([this.hoverId])
+ : new Set<number>(sel ?? []);
+ if (selEdge) focusIds.add(selEdge.from);
+ const bold =
+ this.hoverId === undefined || (sel?.has(this.hoverId) ?? false);
+ const neighbours = new Set<number>();
+
+ const path = (e: GraphEdge) => {
+ const a = ensureExists(pos.get(e.from));
+ const b = ensureExists(pos.get(e.to));
+ const ax = a.x + radiusOf(e.from);
+ const bx = b.x - radiusOf(e.to);
+ const mx = (ax + bx) / 2;
+ return `M ${ax} ${a.y} C ${mx} ${a.y}, ${mx} ${b.y}, ${bx} ${b.y}`;
+ };
+ const edgeHandlers = (e: GraphEdge) => ({
+ onmouseenter: () => {
+ this.hoverEdgeId = e.id;
+ },
+ onmouseleave: () => {
+ if (this.hoverEdgeId === e.id) this.hoverEdgeId = undefined;
+ },
+ onclick: (ev: MouseEvent) => {
+ ev.stopPropagation();
+ attrs.onEdgeSelect?.(e.id);
+ },
+ });
+
+ // Focused (bold) edges for the hovered/selected node(s).
+ const edgeEls: m.Children[] = [];
+ if (focusIds.size > 0) {
+ for (const e of attrs.edges) {
+ if (!focusIds.has(e.from) && !focusIds.has(e.to)) continue;
+ if (!pos.has(e.from) || !pos.has(e.to)) continue;
+ neighbours.add(e.from);
+ neighbours.add(e.to);
+ const d = path(e);
+ edgeEls.push(
+ m('g.pf-lgraph-edge', edgeHandlers(e), [
+ m('path', {
+ 'd': d,
+ 'fill': 'none',
+ 'stroke': 'transparent',
+ // Constant ~12px clickable band at every zoom (non-scaling), so
+ // selection tracks the pointer instead of ballooning when zoomed
+ // in or vanishing when zoomed out.
+ 'stroke-width': 12,
+ 'vector-effect': 'non-scaling-stroke',
+ }),
+ m('path', {
+ 'd': d,
+ 'fill': 'none',
+ 'stroke': e.color,
+ 'stroke-opacity': bold ? 0.95 : 0.5,
+ 'stroke-width': bold ? 1.4 : 0.9,
+ 'stroke-dasharray': e.dashed ? '4,3' : undefined,
+ 'marker-end': 'url(#pf-ng-arrow)',
+ 'vector-effect': 'non-scaling-stroke',
+ }),
+ ]),
+ );
+ }
+ }
+
+ // Super-faint baseline: every edge at very low opacity so the wiring shape
+ // reads at rest. Diff edges (added/removed) are drawn prominently here so a
+ // diff is visible without focusing; unchanged edges stay faint (no diff
+ // artifact). Each carries a fat invisible hit area so even a faint edge is
+ // hoverable/clickable.
+ const baseEls: m.Children[] = [];
+ for (const e of attrs.edges) {
+ if (!pos.has(e.from) || !pos.has(e.to)) continue;
+ const d = path(e);
+ const hovered = this.hoverEdgeId === e.id;
+ const restOpacity = e.diff !== undefined ? 0.6 : 0.08;
+ const restWidth = e.diff !== undefined ? 1.5 : 1;
+ baseEls.push(
+ m('g.pf-lgraph-edge', edgeHandlers(e), [
+ m('path', {
+ d,
+ 'fill': 'none',
+ 'stroke': 'transparent',
+ 'stroke-width': 12,
+ }),
+ m('path', {
+ 'd': d,
+ 'fill': 'none',
+ 'stroke': e.color,
+ 'stroke-opacity': hovered ? 0.9 : restOpacity,
+ 'stroke-width': hovered ? 2 : restWidth,
+ 'stroke-dasharray': e.dashed ? '5,3' : undefined,
+ 'pointer-events': 'none',
+ 'vector-effect': 'non-scaling-stroke',
+ }),
+ ]),
+ );
+ }
+
+ const diffCls = (d?: GraphDiff) =>
+ d === 'added'
+ ? '.pf-lgraph-diff-added'
+ : d === 'removed'
+ ? '.pf-lgraph-diff-removed'
+ : d === 'changed'
+ ? '.pf-lgraph-diff-changed'
+ : '';
+ const nodeEls: m.Children[] = [];
+ for (const [id, p] of pos) {
+ const n = ensureExists(byId.get(id));
+ const isSel = sel?.has(id) ?? false;
+ const dim = focusIds.size > 0 && !focusIds.has(id) && !neighbours.has(id);
+ const label = `${n.label.length > 15 ? n.label.slice(0, 14) + '…' : n.label} ${id}`;
+ const nodeR = radiusOf(id);
+ nodeEls.push(
+ m(
+ 'g.pf-lgraph-node' +
+ (isSel ? '.pf-lgraph-node--sel' : '') +
+ diffCls(n.diff),
+ {
+ onclick: (ev: MouseEvent) => {
+ ev.stopPropagation();
+ attrs.onSelect?.(id, ev.shiftKey);
+ },
+ onmouseenter: () => {
+ this.hoverId = id;
+ },
+ onmouseleave: () => {
+ if (this.hoverId === id && !this.dragging) {
+ this.hoverId = undefined;
+ }
+ },
+ style: dim ? 'opacity:0.25' : undefined,
+ },
+ [
+ // stroke/stroke-width come from CSS (theme-aware); fill + radius here.
+ m('circle', {
+ cx: p.x,
+ cy: p.y,
+ r: isSel ? nodeR + 2 : nodeR,
+ fill: n.fill,
+ }),
+ m(
+ 'text.pf-lgraph-nlabel',
+ {
+ 'x': p.x + nodeR + labelDx,
+ 'y': p.y + 3,
+ 'font-size': 10,
+ 'font-weight': isSel || focusIds.has(id) ? 'bold' : 'normal',
+ },
+ label,
+ ),
+ ],
+ ),
+ );
+ }
+
+ const headers = activeLayers.map((b, ci) =>
+ m(
+ 'text.pf-lgraph-col',
+ {
+ 'x': left + ci * colW,
+ 'y': 18,
+ 'font-size': 11,
+ 'font-weight': 'bold',
+ },
+ attrs.layerLabels[b],
+ ),
+ );
+
+ // Auto-fit to content until the user manually zooms/pans.
+ if (!this.userAdjusted || this.vb === undefined) {
+ const pad = 12;
+ let w = width;
+ let h = height;
+ if (this.vw > 0 && this.vh > 0) {
+ const ar = this.vw / this.vh;
+ if (w / h < ar) h = w / ar;
+ else w = h * ar;
+ }
+ this.vb = {x: -pad, y: -pad, w: w + pad, h: h + pad};
+ }
+ this.cw = width;
+ this.ch = height;
+ const vb = this.vb;
+
+ const tip =
+ this.dragging || !this.sawPointer
+ ? undefined
+ : this.hoverEdgeId !== undefined
+ ? edgesById.get(this.hoverEdgeId)?.tooltip
+ : this.hoverId !== undefined
+ ? byId.get(this.hoverId)?.tooltip
+ : undefined;
+
+ return m('.pf-lgraph-graph', [
+ tip !== undefined && tip !== null
+ ? m(CursorTooltip, {className: 'pf-lgraph-tip'}, tip)
+ : undefined,
+ m('.pf-lgraph-legend', [
+ m(
+ 'span.pf-lgraph-hint',
+ 'click a node or edge for details · hover to peek · scroll = zoom · drag = pan',
+ ),
+ m('span.pf-lgraph-grow'),
+ attrs.legend,
+ m(
+ 'button.pf-lgraph-fit',
+ {
+ onclick: () => {
+ this.userAdjusted = false;
+ },
+ },
+ 'Fit',
+ ),
+ ]),
+ m(
+ 'svg.pf-lgraph-svg',
+ {
+ width: '100%',
+ height: '100%',
+ viewBox: `${vb.x} ${vb.y} ${vb.w} ${vb.h}`,
+ preserveAspectRatio: 'xMidYMid meet',
+ oncreate: (v: m.VnodeDOM) => {
+ const rc = (v.dom as Element).getBoundingClientRect();
+ this.vw = rc.width;
+ this.vh = rc.height;
+ },
+ onupdate: (v: m.VnodeDOM) => {
+ const rc = (v.dom as Element).getBoundingClientRect();
+ if (
+ rc.width > 0 &&
+ (Math.abs(rc.width - this.vw) > 1 ||
+ Math.abs(rc.height - this.vh) > 1)
+ ) {
+ this.vw = rc.width;
+ this.vh = rc.height;
+ if (!this.userAdjusted) m.redraw();
+ }
+ },
+ onwheel: (e: WheelEvent) => this.onWheel(e),
+ onpointerdown: (e: PointerEvent) => this.onDown(e),
+ onpointermove: (e: PointerEvent) => this.onMove(e),
+ onpointerup: () => this.onUp(),
+ onpointerleave: () => this.onUp(),
+ onclick: () => {
+ if (this.didDrag) {
+ this.didDrag = false;
+ return;
+ }
+ attrs.onDeselect?.();
+ },
+ },
+ [
+ m(
+ 'defs',
+ m(
+ 'marker',
+ {
+ id: 'pf-ng-arrow',
+ viewBox: '0 0 10 10',
+ refX: 9,
+ refY: 5,
+ markerWidth: 1.6,
+ markerHeight: 1.6,
+ orient: 'auto-start-reverse',
+ },
+ m('path', {d: 'M 0 0 L 10 5 L 0 10 z', fill: 'context-stroke'}),
+ ),
+ ),
+ // Each layer in its own <g> so the node layer's child count is stable
+ // when focus edges appear/disappear (otherwise unkeyed nodes shift and
+ // the one under the cursor is recreated — hover flicker).
+ m('g.pf-lgraph-layer-headers', headers),
+ m('g.pf-lgraph-layer-base', baseEls),
+ m('g.pf-lgraph-layer-edges', edgeEls),
+ m('g.pf-lgraph-layer-nodes', nodeEls),
+ ],
+ ),
+ ]);
+ }
+}
diff --git a/ui/src/core/embedder/default_plugins.ts b/ui/src/core/embedder/default_plugins.ts
index 46bb22e..2e3a4a3 100644
--- a/ui/src/core/embedder/default_plugins.ts
+++ b/ui/src/core/embedder/default_plugins.ts
@@ -42,6 +42,7 @@
'com.android.HeapDumpExplorer',
'com.android.LargeScreensPerf',
'com.android.MemoryViz',
+ 'com.android.ProcessState',
'com.android.PinAndroidPerfMetrics',
'com.android.PinSysUITracks',
'com.android.SysUIWorkspace',
diff --git a/ui/src/plugins/com.android.ProcessState/grid_helpers.ts b/ui/src/plugins/com.android.ProcessState/grid_helpers.ts
new file mode 100644
index 0000000..6d3a714
--- /dev/null
+++ b/ui/src/plugins/com.android.ProcessState/grid_helpers.ts
@@ -0,0 +1,94 @@
+// Copyright (C) 2026 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 m from 'mithril';
+import {DataGrid} from '../../components/widgets/datagrid/datagrid';
+import type {InMemoryDataSource} from '../../components/widgets/datagrid/in_memory_data_source';
+import type {
+ ColumnSchema,
+ SchemaRegistry,
+} from '../../components/widgets/datagrid/datagrid_schema';
+import type {Row} from '../../trace_processor/query_result';
+
+// Build a DataGrid schema (one column per field). Pid-like columns render as
+// links that drive selection via onPidClick.
+export function gridSchema(
+ columns: ReadonlyArray<string>,
+ onPidClick?: (pid: number) => void,
+ // Per-column renderer; receives the cell value and its row (so diff renderers
+ // can read sibling fields like the baseline value).
+ renderers?: {
+ readonly [col: string]: (
+ value: unknown,
+ row: {[k: string]: unknown},
+ ) => m.Children;
+ },
+): SchemaRegistry {
+ const root: ColumnSchema = {};
+ const isPid = (c: string) =>
+ c === 'pid' ||
+ c === 'client_pid' ||
+ c === 'server_pid' ||
+ c === 'owning_pid' ||
+ c === 'source_pid' ||
+ c === 'host_pid';
+ for (const col of columns) {
+ root[col] = {
+ title: col,
+ // Resolution order: a caller-supplied renderer (e.g. the diff delta), then
+ // the pid-link renderer, else the raw value. Enum columns already arrive
+ // as resolved name strings from the importer, so no enum rendering here.
+ cellRenderer:
+ renderers?.[col] ??
+ (onPidClick && isPid(col)
+ ? (value) =>
+ value === null || value === undefined || Number(value) === 0
+ ? '—' // 0 = no process (global/system event), not navigable
+ : m(
+ 'a.pf-ps-link',
+ {onclick: () => onPidClick(Number(value))},
+ String(value),
+ )
+ : undefined),
+ };
+ }
+ return {root};
+}
+
+// A labelled card whose body is a sortable/filterable DataGrid (or a "none"
+// placeholder). Shared by the explorer page and the timeline details panel.
+export function gridCard(
+ title: string,
+ cols: string[],
+ rows: Row[],
+ ds: InMemoryDataSource | undefined,
+ onPid: (pid: number) => void,
+): m.Children {
+ // Controlled `columns` (not initialColumns): DataGrid reads initialColumns only
+ // once at init, so a reused instance would keep a previous card's columns. With
+ // `columns` the grid reflects the current columns every render — no per-card
+ // Mithril key needed (a key here would clash with the unkeyed siblings in the
+ // panel's renderProps and crash with "vnodes must either all/none have keys").
+ return m('.pf-ps-card', [
+ m('.pf-ps-card-h', title),
+ ds && rows.length
+ ? m(DataGrid, {
+ schema: gridSchema(cols, onPid),
+ rootSchema: 'root',
+ data: ds,
+ columns: cols.map((c) => ({id: c, field: c})),
+ })
+ : m('.pf-ps-card-b', m('.pf-ps-none', '— none —')),
+ ]);
+}
diff --git a/ui/src/plugins/com.android.ProcessState/index.ts b/ui/src/plugins/com.android.ProcessState/index.ts
new file mode 100644
index 0000000..a5ad571
--- /dev/null
+++ b/ui/src/plugins/com.android.ProcessState/index.ts
@@ -0,0 +1,105 @@
+// Copyright (C) 2026 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 './process_state.scss';
+import type {PerfettoPlugin} from '../../public/plugin';
+import type {Trace} from '../../public/trace';
+import {TrackNode} from '../../public/workspace';
+import {CounterTrack} from '../../components/tracks/counter_track';
+import {createProcessStateTrack} from './process_state_track';
+import {BUCKETS} from './process_graph';
+import {buildProcessState} from './relations';
+import {
+ ProcessStateController,
+ SNAPSHOT_TRACK_URI,
+} from './process_state_controller';
+
+const PLUGIN_ID = 'com.android.ProcessState';
+
+// UI for the ActivityManager process/service graph (the data behind `dumpsys
+// activity`). A timeline track of snapshots; selecting one opens the explorer
+// in the details panel — the relationship graph plus the process list and every
+// binding/hosted table, with snapshot scrubbing and diff mode. The panel's
+// layout is responsive (side-by-side when short, stacked when expanded), so the
+// whole experience is self-contained with no separate page.
+export default class implements PerfettoPlugin {
+ static readonly id = PLUGIN_ID;
+
+ async onTraceLoad(ctx: Trace): Promise<void> {
+ await this.maybeAddSnapshotExplorer(ctx);
+ }
+
+ private async maybeAddSnapshotExplorer(ctx: Trace): Promise<void> {
+ if ((await buildProcessState(ctx.engine)) === 0) return;
+
+ const controller = new ProcessStateController(ctx);
+
+ // The snapshot slice track is the group's summary row (collapsed shows the
+ // reason slices); expanding reveals one stepped counter per importance tier
+ // counting the processes in that tier at each snapshot.
+ const uri = SNAPSHOT_TRACK_URI;
+ ctx.tracks.registerTrack({
+ uri,
+ renderer: createProcessStateTrack(ctx, uri, controller),
+ });
+ const group = new TrackNode({
+ uri,
+ name: 'Process state',
+ sortOrder: -50,
+ isSummary: true,
+ });
+ ctx.defaultWorkspace.addChildInOrder(group);
+
+ // Pre-aggregate the per-tier process counts ONCE into a materialized table:
+ // one row per snapshot with a column per importance tier. The nested counters
+ // then read a tiny precomputed column instead of re-aggregating on render.
+ // Aggregate the process table by snapshot_id FIRST, then join the small
+ // (one-row-per-snapshot) result to the snapshot table for its ts. Joining the
+ // two big intrinsic tables first and grouping after is catastrophic (~110s on
+ // a 10M-row trace); group-first is a few seconds.
+ const adj = 'COALESCE(oom_score, 999)'; // NULL oom_score => cached, as in the graph.
+ const sumCols = BUCKETS.map((bucket, i) => {
+ const lower = i === 0 ? undefined : `${adj} > ${BUCKETS[i - 1].maxAdj}`;
+ const upper =
+ bucket.maxAdj === Infinity ? undefined : `${adj} <= ${bucket.maxAdj}`;
+ const range = [lower, upper].filter((c) => c !== undefined).join(' AND ');
+ return `SUM(${range}) AS t${i}`;
+ }).join(',\n ');
+ const outCols = BUCKETS.map((_, i) => `c.t${i}`).join(', ');
+ await ctx.engine.query(`
+ CREATE PERFETTO TABLE _ps_tier_count AS
+ SELECT s.ts AS ts, ${outCols}
+ FROM (
+ SELECT snapshot_id,
+ ${sumCols}
+ FROM __intrinsic_android_process_state_process
+ GROUP BY snapshot_id
+ ) c
+ JOIN __intrinsic_android_process_state_snapshot s ON s.id = c.snapshot_id
+ `);
+
+ BUCKETS.forEach((bucket, i) => {
+ const tierUri = `${uri}/tier/${i}`;
+ ctx.tracks.registerTrack({
+ uri: tierUri,
+ renderer: CounterTrack.create({
+ trace: ctx,
+ uri: tierUri,
+ sqlSource: `SELECT ts, t${i} AS value FROM _ps_tier_count`,
+ }),
+ });
+ group.addChildLast(new TrackNode({uri: tierUri, name: bucket.label}));
+ });
+ }
+}
diff --git a/ui/src/plugins/com.android.ProcessState/process_graph.ts b/ui/src/plugins/com.android.ProcessState/process_graph.ts
new file mode 100644
index 0000000..1474afe
--- /dev/null
+++ b/ui/src/plugins/com.android.ProcessState/process_graph.ts
@@ -0,0 +1,310 @@
+// Copyright (C) 2026 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 m from 'mithril';
+import type {Trace} from '../../public/trace';
+import type {Row} from '../../trace_processor/query_result';
+import {NUM, STR_NULL} from '../../trace_processor/query_result';
+import {LayeredGraph} from '../../components/widgets/charts_svg/layered_graph';
+import type {
+ GraphEdge,
+ GraphNode,
+} from '../../components/widgets/charts_svg/layered_graph';
+
+export interface EdgeSel {
+ readonly from: number;
+ readonly to: number;
+ readonly kind: 'service' | 'provider';
+ readonly count: number;
+ readonly names: string;
+ readonly fg: boolean;
+}
+
+// Identity of an edge for selection toggling (count/names/fg are payload).
+export function sameEdge(a: EdgeSel, b: EdgeSel): boolean {
+ return a.from === b.from && a.to === b.to && a.kind === b.kind;
+}
+
+// Per-node diff status vs a baseline snapshot. Absent = unchanged.
+export type DiffStatus = 'added' | 'removed' | 'changed';
+
+export interface ProcessGraphAttrs {
+ readonly trace: Trace;
+ readonly processes: ReadonlyArray<Row>;
+ readonly bindingsQuery: number; // snapshot id
+ // Diff mode: status per pid vs the baseline, and the baseline snapshot id so
+ // edges can be diffed too. `processes` is expected to already include the
+ // removed (baseline-only) processes so they have a position.
+ readonly diffNodes?: ReadonlyMap<number, DiffStatus>;
+ readonly diffBaseline?: number;
+ readonly selectedPids?: ReadonlySet<number>;
+ readonly selectedEdge?: EdgeSel;
+ readonly onSelect: (pid: number, additive: boolean) => void;
+ readonly onEdgeSelect?: (e: EdgeSel) => void;
+ readonly onDeselect?: () => void;
+}
+
+// One service / content-provider binding edge (client -> hosting process),
+// summarised across the underlying ConnectionRecords.
+interface Edge {
+ readonly from: number;
+ readonly to: number;
+ readonly fg: boolean;
+ readonly kind: 'service' | 'provider';
+ readonly count: number;
+ readonly names: string;
+ diff?: DiffStatus; // 'added' / 'removed' vs the baseline (diff mode only)
+}
+
+const edgeKey = (from: number, to: number, kind: string) =>
+ `${from}->${to}:${kind}`;
+
+// oom-adj importance columns (buckets): lower adj = more important = further
+// left. The order / label / colour / inclusive adj upper-bound of each bucket
+// is the layout config LayeredGraph is driven by.
+// Importance tiers, ordered most→least important. A process belongs to the
+// first bucket whose maxAdj its oom_score is <= (i.e. the lower bound is the
+// previous bucket's maxAdj, exclusive). Shared with the snapshot track's nested
+// per-tier process-count counters, so this stays the single source of truth.
+export interface Bucket {
+ readonly label: string;
+ readonly color: string; // Tableau 10 — muted, even-weight
+ readonly maxAdj: number;
+}
+export const BUCKETS: ReadonlyArray<Bucket> = [
+ {label: 'persistent', color: '#e15759', maxAdj: -1},
+ {label: 'foreground & visible', color: '#59a14f', maxAdj: 100},
+ {label: 'perceptible', color: '#4e79a7', maxAdj: 200},
+ {label: 'service', color: '#b07aa1', maxAdj: 250},
+ {label: 'background', color: '#f28e2b', maxAdj: 899},
+ {label: 'cached', color: '#bab0ac', maxAdj: Infinity},
+];
+function tier(adj: number): number {
+ const i = BUCKETS.findIndex((b) => adj <= b.maxAdj);
+ return i < 0 ? BUCKETS.length - 1 : i;
+}
+const BUCKET_LABELS = BUCKETS.map((b) => b.label);
+
+const EDGE_SERVICE = '#bab0ac';
+const EDGE_PROVIDER = '#4e79a7';
+const EDGE_FG = '#e15759';
+// Diff palette: green = added, red = removed. Shared with the node CSS classes
+// (.pf-lgraph-diff-*) and the page's in-place column deltas.
+const DIFF_ADDED = '#43a047';
+const DIFF_REMOVED = '#e53935';
+// In diff mode every node is neutral grey so the green/amber/red diff rings
+// pop instead of competing with the tier colours (e.g. red persistent vs red
+// removed).
+const DIFF_NEUTRAL = '#c4c4c4';
+
+// Adapter that turns an ActivityManager snapshot (processes + their service /
+// content-provider bindings) into the generic LayeredGraph's data model: this is
+// the only place that knows about processes, oom-adj tiers and bindings.
+export class ProcessGraph implements m.ClassComponent<ProcessGraphAttrs> {
+ private edges: Edge[] = [];
+ private loadedKey = '';
+
+ private async loadEdgesFor(
+ attrs: ProcessGraphAttrs,
+ snap: number,
+ ): Promise<Edge[]> {
+ const q = await attrs.trace.engine.query(`
+ SELECT b.client_pid AS f, s.owning_pid AS t,
+ IFNULL(max(b.foreground), 0) AS fg, count(*) AS cnt,
+ group_concat(DISTINCT s.name) AS names
+ FROM _ps_service_binding b
+ JOIN _ps_service s
+ ON s.snapshot_id = b.snapshot_id AND s.service_id = b.service_id
+ WHERE b.snapshot_id = ${snap} AND b.client_pid != s.owning_pid
+ GROUP BY b.client_pid, s.owning_pid`);
+ const it = q.iter({f: NUM, t: NUM, fg: NUM, cnt: NUM, names: STR_NULL});
+ const edges: Edge[] = [];
+ for (; it.valid(); it.next()) {
+ edges.push({
+ from: it.f,
+ to: it.t,
+ fg: it.fg > 0,
+ kind: 'service',
+ count: it.cnt,
+ names: it.names ?? '',
+ });
+ }
+ const pq = await attrs.trace.engine.query(`
+ SELECT pb.client_pid AS f, p.owning_pid AS t, count(*) AS cnt,
+ group_concat(DISTINCT p.authority) AS names
+ FROM _ps_provider_binding pb
+ JOIN _ps_provider p
+ ON p.snapshot_id = pb.snapshot_id AND p.provider_id = pb.provider_id
+ WHERE pb.snapshot_id = ${snap} AND pb.client_pid != p.owning_pid
+ GROUP BY pb.client_pid, p.owning_pid`);
+ const pit = pq.iter({f: NUM, t: NUM, cnt: NUM, names: STR_NULL});
+ for (; pit.valid(); pit.next()) {
+ edges.push({
+ from: pit.f,
+ to: pit.t,
+ fg: false,
+ kind: 'provider',
+ count: pit.cnt,
+ names: pit.names ?? '',
+ });
+ }
+ return edges;
+ }
+
+ private async load(attrs: ProcessGraphAttrs) {
+ // Mark as loaded up front so a query error can't re-trigger load() every
+ // render (the edges just keep their previous value on failure).
+ this.loadedKey = `${attrs.bindingsQuery}:${attrs.diffBaseline ?? ''}`;
+ const cur = await this.loadEdgesFor(attrs, attrs.bindingsQuery);
+ if (attrs.diffBaseline === undefined) {
+ this.edges = cur;
+ } else {
+ // Diff mode: tag current-only edges 'added', append baseline-only edges
+ // as 'removed'; same-key edges are left unchanged.
+ const base = await this.loadEdgesFor(attrs, attrs.diffBaseline);
+ const baseKeys = new Set(base.map((e) => edgeKey(e.from, e.to, e.kind)));
+ const curKeys = new Set(cur.map((e) => edgeKey(e.from, e.to, e.kind)));
+ const merged: Edge[] = cur.map((e) => ({
+ ...e,
+ diff: baseKeys.has(edgeKey(e.from, e.to, e.kind))
+ ? undefined
+ : ('added' as DiffStatus),
+ }));
+ for (const e of base) {
+ if (!curKeys.has(edgeKey(e.from, e.to, e.kind))) {
+ merged.push({...e, diff: 'removed'});
+ }
+ }
+ this.edges = merged;
+ }
+ m.redraw();
+ }
+
+ // Tooltips surface what the graph can't show — the full name and the binding
+ // kind; everything else is in the detail tables on click.
+ private nodeTooltip(name: string, pid: number): m.Children {
+ return [
+ m('.pf-lgraph-tip-name', name),
+ m('.pf-lgraph-tip-sub', `pid ${pid}`),
+ ];
+ }
+ private edgeTooltip(e: Edge, nm: (pid: number) => string): m.Children {
+ const kind =
+ e.kind === 'provider'
+ ? 'content-provider binding'
+ : e.fg
+ ? 'foreground service binding'
+ : 'service binding';
+ return [
+ m('.pf-lgraph-tip-name', kind),
+ m('.pf-lgraph-tip-sub', `${nm(e.from)} → ${nm(e.to)}`),
+ ];
+ }
+
+ view({attrs}: m.Vnode<ProcessGraphAttrs>) {
+ const loadKey = `${attrs.bindingsQuery}:${attrs.diffBaseline ?? ''}`;
+ if (this.loadedKey !== loadKey) {
+ this.load(attrs).catch((e) => console.error('ProcessGraph', e));
+ }
+
+ const fullName = (p: Row) => String(p['name'] ?? p['pid']);
+ const nameByPid = new Map<number, string>(
+ attrs.processes.map((p) => [Number(p['pid']), fullName(p)]),
+ );
+ const nm = (pid: number) => nameByPid.get(pid) ?? String(pid);
+
+ const nodes: GraphNode[] = attrs.processes.map((p) => {
+ const pid = Number(p['pid']);
+ const t = tier(Number(p['oom_score'] ?? 999));
+ return {
+ id: pid,
+ label: fullName(p).replace(/^.*\//, ''),
+ layer: t,
+ fill:
+ attrs.diffBaseline !== undefined ? DIFF_NEUTRAL : BUCKETS[t].color,
+ diff: attrs.diffNodes?.get(pid),
+ tooltip: this.nodeTooltip(fullName(p), pid),
+ };
+ });
+
+ const edges: GraphEdge[] = this.edges.map((e) => ({
+ id: edgeKey(e.from, e.to, e.kind),
+ from: e.from,
+ to: e.to,
+ color:
+ e.diff === 'added'
+ ? DIFF_ADDED
+ : e.diff === 'removed'
+ ? DIFF_REMOVED
+ : e.kind === 'provider'
+ ? EDGE_PROVIDER
+ : e.fg
+ ? EDGE_FG
+ : EDGE_SERVICE,
+ dashed: e.kind === 'provider' || e.diff === 'removed',
+ diff:
+ e.diff === 'added'
+ ? 'added'
+ : e.diff === 'removed'
+ ? 'removed'
+ : undefined,
+ tooltip: this.edgeTooltip(e, nm),
+ }));
+
+ const legend =
+ attrs.diffBaseline !== undefined
+ ? [
+ m('span.pf-lgraph-diff-leg.pf-lgraph-diff-added', '● added'),
+ m('span.pf-lgraph-diff-leg.pf-lgraph-diff-changed', '● changed'),
+ m('span.pf-lgraph-diff-leg.pf-lgraph-diff-removed', '╌ removed'),
+ ]
+ : [
+ m('span', {style: `color:${EDGE_SERVICE}`}, '── service'),
+ m('span', {style: `color:${EDGE_FG}`}, '── foreground'),
+ m('span', {style: `color:${EDGE_PROVIDER}`}, '╌╌ provider'),
+ ];
+
+ return m(LayeredGraph, {
+ nodes,
+ edges,
+ layerLabels: BUCKET_LABELS,
+ selectedIds: attrs.selectedPids,
+ selectedEdgeId: attrs.selectedEdge
+ ? edgeKey(
+ attrs.selectedEdge.from,
+ attrs.selectedEdge.to,
+ attrs.selectedEdge.kind,
+ )
+ : undefined,
+ legend,
+ onSelect: (id, additive) => attrs.onSelect(id, additive),
+ onEdgeSelect: (edgeId) => {
+ const e = this.edges.find(
+ (x) => edgeKey(x.from, x.to, x.kind) === edgeId,
+ );
+ if (e !== undefined) {
+ attrs.onEdgeSelect?.({
+ from: e.from,
+ to: e.to,
+ kind: e.kind,
+ count: e.count,
+ names: e.names,
+ fg: e.fg,
+ });
+ }
+ },
+ onDeselect: () => attrs.onDeselect?.(),
+ });
+ }
+}
diff --git a/ui/src/plugins/com.android.ProcessState/process_state.scss b/ui/src/plugins/com.android.ProcessState/process_state.scss
new file mode 100644
index 0000000..73593d6
--- /dev/null
+++ b/ui/src/plugins/com.android.ProcessState/process_state.scss
@@ -0,0 +1,155 @@
+// com.android.ProcessState styles. The explorer lives in the timeline details
+// panel (.pf-ps-detailpanel): the relationship graph (the shared LayeredGraph
+// charts_svg widget) and a tabbed Current / Process list pane.
+
+// Clickable pid links inside the grids (rendered by grid_helpers.gridSchema).
+.pf-ps-link {
+ color: var(--pf-color-primary, #1565c0);
+ cursor: pointer;
+ &:hover {
+ text-decoration: underline;
+ }
+}
+
+// Diff palette (green added / amber changed / red removed), shared by the
+// graph legend swatches and the Process-list "change" column badge. Matches
+// the DIFF_ADDED / DIFF_REMOVED edge colours in process_graph.ts.
+.pf-ps-diff-added {
+ color: #2e7d32;
+}
+.pf-ps-diff-changed {
+ color: #ef6c00;
+}
+.pf-ps-diff-removed {
+ color: #c62828;
+}
+.pf-ps-diff-badge {
+ font-weight: bold;
+ white-space: nowrap;
+}
+.pf-ps-diff-leg {
+ font-size: 11px;
+ margin-left: 10px;
+}
+
+// ---- shared layout bits (used by BOTH the full page and the details panel) ----
+.pf-ps-tabs {
+ flex: 0 0 auto;
+ display: flex;
+ gap: 2px;
+ padding: 4px 0;
+ border-bottom: 1px solid var(--pf-color-border, #eee);
+}
+.pf-ps-tab {
+ border: none;
+ background: transparent;
+ cursor: pointer;
+ font-size: 12px;
+ padding: 5px 12px;
+ border-bottom: 2px solid transparent;
+ color: var(--pf-color-text-muted, #777);
+}
+.pf-ps-tab--on {
+ color: var(--pf-color-text, #111);
+ font-weight: bold;
+ border-bottom-color: var(--pf-color-primary, #1565c0);
+}
+.pf-ps-tabbody {
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+}
+.pf-ps-tabbody > .pf-data-grid {
+ flex: 1;
+ min-height: 0;
+}
+.pf-ps-tabbody--scroll {
+ display: block;
+ overflow: auto;
+ padding-top: 8px;
+}
+.pf-ps-card {
+ border: 1px solid var(--pf-color-border, #e2e2e2);
+ margin-bottom: 8px;
+ .pf-data-grid {
+ max-height: 220px;
+ }
+ &:last-child {
+ margin-bottom: 0;
+ }
+}
+.pf-ps-card-h {
+ background: var(--pf-color-background-secondary);
+ padding: 4px 10px;
+ font-weight: bold;
+ font-size: 11px;
+ text-transform: uppercase;
+ color: var(--pf-color-text-muted, #555);
+}
+.pf-ps-card-b {
+ padding: 8px 10px;
+}
+.pf-ps-detail-h {
+ display: flex;
+ align-items: baseline;
+ gap: 10px;
+ margin-bottom: 6px;
+}
+.pf-ps-detail-title {
+ font-size: 14px;
+ font-weight: bold;
+}
+.pf-ps-detail-sub {
+ font-family: monospace;
+ color: var(--pf-color-text-muted, #777);
+}
+.pf-ps-none {
+ padding: 6px 2px;
+ color: var(--pf-color-text-muted, #999);
+ font-size: 12px;
+}
+// ---- timeline details panel: the whole explorer (no separate page) ----
+// `.pf-ps-detailpanel` is the DetailsShell ROOT; make its `.pf-content` a flex
+// column so the SplitPanel fills the drawer's real height. The split's
+// orientation (and resize handle) is owned by the shared SplitPanel widget; we
+// only flip `direction` based on the drawer height.
+.pf-ps-detailpanel {
+ height: 100%;
+ min-height: 0;
+ font-size: 12px;
+ .pf-content {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ overflow: hidden;
+ }
+ // Wrapper that hands the SplitPanel the drawer height (and whose height we
+ // measure to choose the orientation).
+ .pf-ps-splitwrap {
+ flex: 1 1 auto;
+ min-height: 0;
+ min-width: 0;
+ display: flex;
+ }
+ // The graph fills its SplitPanel pane.
+ .pf-lgraph-graph {
+ flex: 1;
+ min-height: 0;
+ }
+ // Detail pane (tabs + tables) fills the other pane.
+ .pf-ps-bottom {
+ // The SplitPanel pane (.pf-split-panel__second) is a sized flex item but is
+ // itself display:block, so flex:1 here has no flex parent to grow against.
+ // Fill the pane explicitly (like the graph does) so the tab body — and the
+ // fillHeight DataGrid inside it — gets a bounded height and scrolls instead
+ // of overflowing and being clipped.
+ height: 100%;
+ min-width: 0;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ padding: 0 8px;
+ }
+}
diff --git a/ui/src/plugins/com.android.ProcessState/process_state_controller.ts b/ui/src/plugins/com.android.ProcessState/process_state_controller.ts
new file mode 100644
index 0000000..0135ad2
--- /dev/null
+++ b/ui/src/plugins/com.android.ProcessState/process_state_controller.ts
@@ -0,0 +1,489 @@
+// Copyright (C) 2026 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 m from 'mithril';
+import type {Trace} from '../../public/trace';
+import type {Row} from '../../trace_processor/query_result';
+import {LONG, NUM, STR_NULL} from '../../trace_processor/query_result';
+import {InMemoryDataSource} from '../../components/widgets/datagrid/in_memory_data_source';
+import {type DiffStatus, type EdgeSel, sameEdge} from './process_graph';
+
+// URI of the single snapshot timeline track. The slice id IS the snapshot id,
+// so a snapshot id is a valid event id on this track — letting the explorer
+// keep the timeline slice highlight in sync with the scrubber.
+export const SNAPSHOT_TRACK_URI = '/process_state_snapshots';
+
+// Process fields compared in diff mode; a change is shown in the field's own
+// grid column as "old → new". (All already arrive as display strings.)
+const DIFF_COLS = ['oom_score', 'proc_state', 'capabilities'];
+
+export interface SnapshotInfo {
+ readonly id: number;
+ readonly ts: bigint;
+ // OomChangeReasonEnum name (already resolved by the importer); undefined for a
+ // one-shot dumpsys snapshot.
+ readonly reason?: string;
+}
+
+// Single source of truth for the ProcessState explorer: created once per trace
+// and shared by the (one) details-panel surface. Holds all selection / view
+// state and the loaded snapshot data; views are thin renderers over it and
+// mutate only through its methods, which load lazily and redraw. No global
+// state.
+export class ProcessStateController {
+ readonly trace: Trace;
+
+ // ---- view state (the shared selection) ----
+ snapshots: ReadonlyArray<SnapshotInfo> = [];
+ snapshotId?: number;
+ selectedPid?: number;
+ selectedEdge?: EdgeSel;
+ diffOn = false;
+ baselineId?: number;
+ // When true (the default), the diff baseline follows the current snapshot's
+ // immediately-previous one as you scrub ("what did this event change"). When
+ // false, the baseline is pinned to a chosen snapshot (cumulative "since X").
+ baselineFollowsPrev = true;
+ tab: 'current' | 'procs' = 'procs';
+
+ // ---- loaded data for the current snapshot ----
+ processes: Row[] = [];
+ procCols: string[] = [];
+ // Union of current + baseline-only "removed" processes (== processes when not
+ // diffing); what the graph and the process list are drawn from.
+ graphProcesses: Row[] = [];
+ procDs?: InMemoryDataSource;
+ diffNodes = new Map<number, DiffStatus>();
+
+ // ---- selected-process detail ----
+ stateRows: Row[] = [];
+ stateDs?: InMemoryDataSource;
+ hostedSvc: Row[] = [];
+ hostedSvcCols: string[] = [];
+ hostedSvcDs?: InMemoryDataSource;
+ hostedProv: Row[] = [];
+ hostedProvCols: string[] = [];
+ hostedProvDs?: InMemoryDataSource;
+ outAll: Row[] = [];
+ outDs?: InMemoryDataSource;
+ inAll: Row[] = [];
+ inDs?: InMemoryDataSource;
+ selfAll: Row[] = [];
+ selfDs?: InMemoryDataSource;
+
+ // ---- selected-edge detail ----
+ edgeRows: Row[] = [];
+ edgeDs?: InMemoryDataSource;
+ edgeNames: Row[] = [];
+ edgeNamesDs?: InMemoryDataSource;
+
+ private loadToken = 0;
+ private snapshotsLoaded = false;
+
+ constructor(trace: Trace) {
+ this.trace = trace;
+ }
+
+ reasonOf(id: number | undefined): string | undefined {
+ return this.snapshots.find((s) => s.id === id)?.reason ?? undefined;
+ }
+
+ // Loads the snapshot list (once) and selects `id`, or the latest if absent.
+ async ensureLoaded(id?: number) {
+ if (!this.snapshotsLoaded) {
+ const q = await this.trace.engine.query(
+ `SELECT id, ts, reason FROM _ps_snapshot ORDER BY ts`,
+ );
+ const snaps: SnapshotInfo[] = [];
+ const it = q.iter({id: NUM, ts: LONG, reason: STR_NULL});
+ for (; it.valid(); it.next()) {
+ snaps.push({id: it.id, ts: it.ts, reason: it.reason ?? undefined});
+ }
+ this.snapshots = snaps;
+ this.snapshotsLoaded = true;
+ }
+ const want =
+ id ??
+ this.snapshotId ??
+ (this.snapshots.length > 0
+ ? this.snapshots[this.snapshots.length - 1].id
+ : undefined);
+ if (want !== undefined && want !== this.snapshotId) {
+ await this.setSnapshot(want);
+ } else if (this.processes.length === 0 && want !== undefined) {
+ await this.setSnapshot(want);
+ }
+ }
+
+ // Move to a snapshot AND highlight its slice on the timeline, so the scrubber
+ // and the timeline selection stay in sync. setSnapshot updates snapshotId
+ // synchronously, so the selectTrackEvent below re-enters load()/ensureLoaded
+ // as a no-op (no reload loop).
+ goToSnapshot(id: number) {
+ this.setSnapshot(id).catch((e) => console.error('ProcessState', e));
+ this.trace.selection.selectTrackEvent(SNAPSHOT_TRACK_URI, id);
+ }
+
+ async setSnapshot(id: number) {
+ this.snapshotId = id;
+ this.selectedPid = undefined;
+ this.selectedEdge = undefined;
+ this.clearSelectedData();
+ const token = ++this.loadToken;
+ const q = await this.trace.engine.query(`
+ SELECT * FROM _ps_process WHERE snapshot_id = ${id} ORDER BY oom_score`);
+ if (token !== this.loadToken) return;
+ this.procCols = q.columns();
+ this.processes = this.rowsOf(q);
+ // In follow mode the baseline tracks the new snapshot's previous one; a
+ // pinned baseline stays put (but never equal to the current snapshot).
+ if (this.baselineFollowsPrev || this.baselineId === id) {
+ this.baselineId = this.prevSnapshotId(id);
+ }
+ await this.refreshDiff(token);
+ m.redraw();
+ }
+
+ prevSnapshotId(id: number): number | undefined {
+ const i = this.snapshots.findIndex((s) => s.id === id);
+ return i > 0 ? this.snapshots[i - 1].id : undefined;
+ }
+
+ // ---- selection ----
+
+ select(pid: number) {
+ if (this.selectedPid === pid && this.selectedEdge === undefined) {
+ this.selectedPid = undefined;
+ this.clearSelectedData();
+ this.tab = 'procs';
+ m.redraw();
+ return;
+ }
+ this.selectedPid = pid;
+ this.selectedEdge = undefined;
+ this.tab = 'current';
+ this.loadSelected(pid)
+ .then(() => m.redraw())
+ .catch((e) => console.error('ProcessState', e));
+ }
+
+ selectEdge(e: EdgeSel) {
+ if (this.selectedEdge !== undefined && sameEdge(this.selectedEdge, e)) {
+ this.selectedEdge = undefined;
+ this.tab = this.selectedPid !== undefined ? 'current' : 'procs';
+ m.redraw();
+ return;
+ }
+ this.selectedEdge = e;
+ this.buildEdge(e);
+ this.tab = 'current';
+ m.redraw();
+ }
+
+ deselect() {
+ this.selectedPid = undefined;
+ this.selectedEdge = undefined;
+ this.clearSelectedData();
+ this.tab = 'procs';
+ m.redraw();
+ }
+
+ setTab(tab: 'current' | 'procs') {
+ this.tab = tab;
+ m.redraw();
+ }
+
+ toggleDiff() {
+ this.diffOn = !this.diffOn;
+ // Default to the follow-previous view each time diff is (re)enabled.
+ if (
+ this.diffOn &&
+ this.baselineFollowsPrev &&
+ this.snapshotId !== undefined
+ ) {
+ this.baselineId = this.prevSnapshotId(this.snapshotId);
+ }
+ this.refreshDiff(this.loadToken)
+ .then(() => m.redraw())
+ .catch((e) => console.error('ProcessState', e));
+ }
+
+ // Switch back to following the previous snapshot (the auto baseline).
+ followPrevBaseline() {
+ this.baselineFollowsPrev = true;
+ if (this.snapshotId !== undefined) {
+ this.baselineId = this.prevSnapshotId(this.snapshotId);
+ }
+ this.refreshDiff(this.loadToken)
+ .then(() => m.redraw())
+ .catch((e) => console.error('ProcessState', e));
+ }
+
+ // Pin the baseline to a specific snapshot (cumulative diff since that point).
+ setBaseline(id: number) {
+ this.baselineFollowsPrev = false;
+ this.baselineId = id;
+ this.refreshDiff(this.loadToken)
+ .then(() => m.redraw())
+ .catch((e) => console.error('ProcessState', e));
+ }
+
+ // ---- data loading ----
+
+ private clearSelectedData() {
+ this.stateRows = [];
+ this.hostedSvc = this.hostedProv = [];
+ this.outAll = this.inAll = this.selfAll = [];
+ this.stateDs = this.hostedSvcDs = this.hostedProvDs = undefined;
+ this.outDs = this.inDs = this.selfDs = undefined;
+ }
+
+ // Recomputes graphProcesses / diffNodes / the process-list rows for the
+ // current diff state.
+ private async refreshDiff(token: number) {
+ const id = this.snapshotId;
+ if (id === undefined) return;
+ if (!this.diffOn || this.baselineId === undefined) {
+ this.diffNodes = new Map();
+ this.graphProcesses = this.processes;
+ this.procDs = new InMemoryDataSource(this.processes);
+ return;
+ }
+ const bq = await this.trace.engine.query(
+ `SELECT * FROM _ps_process WHERE snapshot_id = ${this.baselineId}`,
+ );
+ if (token !== this.loadToken) return;
+ const base = this.rowsOf(bq);
+ const baseByPid = new Map(base.map((r) => [Number(r['pid']), r]));
+ const curByPid = new Map(this.processes.map((r) => [Number(r['pid']), r]));
+ const nodes = new Map<number, DiffStatus>();
+ // The process list shows the CURRENT snapshot; a changed field is rewritten
+ // in place to "old → new" (encoded in the cell's own value so it survives
+ // the DataGrid's column projection). Removed processes aren't in the current
+ // snapshot, so they appear only as ghosts in the graph, not the list.
+ const listRows: Row[] = [];
+ for (const r of this.processes) {
+ const pid = Number(r['pid']);
+ const b = baseByPid.get(pid);
+ if (b === undefined) {
+ nodes.set(pid, 'added');
+ listRows.push({...r});
+ continue;
+ }
+ const out: Row = {...r};
+ let changed = false;
+ for (const col of DIFF_COLS) {
+ if (String(b[col] ?? '') !== String(r[col] ?? '')) {
+ out[col] = `${fmt(b[col])} → ${fmt(r[col])}`;
+ changed = true;
+ }
+ }
+ if (changed) nodes.set(pid, 'changed');
+ listRows.push(out);
+ }
+ const removed: Row[] = [];
+ for (const r of base) {
+ if (!curByPid.has(Number(r['pid']))) {
+ nodes.set(Number(r['pid']), 'removed');
+ removed.push(r);
+ }
+ }
+ this.diffNodes = nodes;
+ this.graphProcesses = [...this.processes, ...removed];
+ this.procDs = new InMemoryDataSource(listRows);
+ }
+
+ private async loadSelected(pid: number) {
+ const id = this.snapshotId;
+ if (id === undefined) return;
+ const token = this.loadToken;
+ const outQ = await this.trace.engine.query(`
+ SELECT s.owning_pid AS server_pid, s.name AS service,
+ MAX(b.foreground) AS fg, COUNT(*) AS n
+ FROM _ps_service_binding b
+ LEFT JOIN _ps_service s
+ ON s.snapshot_id = b.snapshot_id AND s.service_id = b.service_id
+ WHERE b.snapshot_id = ${id} AND b.client_pid = ${pid}
+ AND s.owning_pid != ${pid}
+ GROUP BY s.owning_pid, b.service_id`);
+ const provOutQ = await this.trace.engine.query(`
+ SELECT p.owning_pid AS server_pid, p.authority, COUNT(*) AS n
+ FROM _ps_provider_binding pb
+ JOIN _ps_provider p
+ ON p.snapshot_id = pb.snapshot_id AND p.provider_id = pb.provider_id
+ WHERE pb.snapshot_id = ${id} AND pb.client_pid = ${pid}
+ AND p.owning_pid != ${pid}
+ GROUP BY p.owning_pid, pb.provider_id`);
+ const inQ = await this.trace.engine.query(`
+ SELECT b.client_pid, s.name AS service,
+ MAX(b.foreground) AS fg, COUNT(*) AS n
+ FROM _ps_service_binding b
+ JOIN _ps_service s
+ ON s.snapshot_id = b.snapshot_id AND s.service_id = b.service_id
+ WHERE b.snapshot_id = ${id} AND s.owning_pid = ${pid}
+ AND b.client_pid != ${pid}
+ GROUP BY b.client_pid, b.service_id`);
+ const provInQ = await this.trace.engine.query(`
+ SELECT pb.client_pid, p.authority, COUNT(*) AS n
+ FROM _ps_provider_binding pb
+ JOIN _ps_provider p
+ ON p.snapshot_id = pb.snapshot_id AND p.provider_id = pb.provider_id
+ WHERE pb.snapshot_id = ${id} AND p.owning_pid = ${pid}
+ AND pb.client_pid != ${pid}
+ GROUP BY pb.client_pid, pb.provider_id`);
+ const selfSvcQ = await this.trace.engine.query(`
+ SELECT s.name AS service, MAX(b.foreground) AS fg, COUNT(*) AS n
+ FROM _ps_service_binding b
+ JOIN _ps_service s
+ ON s.snapshot_id = b.snapshot_id AND s.service_id = b.service_id
+ WHERE b.snapshot_id = ${id} AND b.client_pid = ${pid}
+ AND s.owning_pid = ${pid}
+ GROUP BY b.service_id`);
+ const selfProvQ = await this.trace.engine.query(`
+ SELECT p.authority, COUNT(*) AS n
+ FROM _ps_provider_binding pb
+ JOIN _ps_provider p
+ ON p.snapshot_id = pb.snapshot_id AND p.provider_id = pb.provider_id
+ WHERE pb.snapshot_id = ${id} AND pb.client_pid = ${pid}
+ AND p.owning_pid = ${pid}
+ GROUP BY pb.provider_id`);
+ const hsvcQ = await this.trace.engine.query(
+ `SELECT * FROM _ps_service WHERE snapshot_id = ${id} AND owning_pid = ${pid}`,
+ );
+ const hprovQ = await this.trace.engine.query(
+ `SELECT * FROM _ps_provider WHERE snapshot_id = ${id} AND owning_pid = ${pid}`,
+ );
+ if (token !== this.loadToken) return;
+
+ this.outAll = [
+ ...this.bindRows(outQ, 'server_pid', 'service', true),
+ ...this.bindRows(provOutQ, 'server_pid', 'authority', false),
+ ];
+ this.inAll = [
+ ...this.bindRows(inQ, 'client_pid', 'service', true),
+ ...this.bindRows(provInQ, 'client_pid', 'authority', false),
+ ];
+ this.selfAll = [
+ ...this.selfRows(selfSvcQ, 'service', true),
+ ...this.selfRows(selfProvQ, 'authority', false),
+ ];
+ this.hostedSvc = this.rowsOf(hsvcQ);
+ this.hostedSvcCols = hsvcQ.columns();
+ this.hostedProv = this.rowsOf(hprovQ);
+ this.hostedProvCols = hprovQ.columns();
+
+ this.buildStateCard(pid);
+ this.outDs = new InMemoryDataSource(this.outAll);
+ this.inDs = new InMemoryDataSource(this.inAll);
+ this.selfDs = new InMemoryDataSource(this.selfAll);
+ this.hostedSvcDs = new InMemoryDataSource(this.hostedSvc);
+ this.hostedProvDs = new InMemoryDataSource(this.hostedProv);
+ }
+
+ // Map a binding/provider summary query to {pid,kind,name,fg,n} grid rows.
+ private bindRows(
+ q: ReturnType<Trace['engine']['query']> extends Promise<infer R>
+ ? R
+ : never,
+ pidCol: string,
+ nameCol: string,
+ isSvc: boolean,
+ ): Row[] {
+ return this.rowsOf(q).map((b) => ({
+ pid: Number(b[pidCol] ?? 0),
+ kind: isSvc ? 'service' : 'provider',
+ name: String(b[nameCol] ?? ''),
+ fg: isSvc && Number(b['fg']) ? 'fg' : '',
+ n: Number(b['n'] ?? 1),
+ }));
+ }
+ private selfRows(
+ q: ReturnType<Trace['engine']['query']> extends Promise<infer R>
+ ? R
+ : never,
+ nameCol: string,
+ isSvc: boolean,
+ ): Row[] {
+ return this.rowsOf(q).map((b) => ({
+ kind: isSvc ? 'service' : 'provider',
+ name: String(b[nameCol] ?? ''),
+ fg: isSvc && Number(b['fg']) ? 'fg' : '',
+ n: Number(b['n'] ?? 1),
+ }));
+ }
+
+ private buildStateCard(pid: number) {
+ const p = this.graphProcesses.find((r) => Number(r['pid']) === pid);
+ // Empty cells show the SQL value NULL (not "—"/"none"/"no") so a missing
+ // value is visibly distinct from a real 0/false.
+ const v = (k: string) =>
+ !p || p[k] === null || p[k] === undefined ? 'NULL' : String(p[k]);
+ const persistent =
+ !p || p['persistent'] === null || p['persistent'] === undefined
+ ? 'NULL'
+ : Number(p['persistent'])
+ ? 'yes'
+ : 'no';
+ this.stateRows = [
+ {property: 'oom adj', value: v('oom_score')},
+ {property: 'proc state', value: v('proc_state')},
+ {property: 'capabilities', value: v('capabilities')},
+ {property: 'persistent', value: persistent},
+ ];
+ this.stateDs = new InMemoryDataSource(this.stateRows);
+ }
+
+ private buildEdge(e: EdgeSel) {
+ this.edgeRows = [
+ {
+ client_pid: e.from,
+ host_pid: e.to,
+ connections: e.count,
+ foreground: e.fg ? 'yes' : 'no',
+ },
+ ];
+ const col = e.kind === 'provider' ? 'authority' : 'service';
+ this.edgeNames = (e.names ? e.names.split(',') : []).map((n) => ({
+ [col]: n,
+ }));
+ this.edgeDs = new InMemoryDataSource(this.edgeRows);
+ this.edgeNamesDs = new InMemoryDataSource(this.edgeNames);
+ }
+
+ nameOf(pid: number): string {
+ const r = this.graphProcesses.find((p) => Number(p['pid']) === pid);
+ return r ? String(r['name'] ?? pid).replace(/^.*\//, '') : String(pid);
+ }
+
+ private rowsOf(
+ q: ReturnType<Trace['engine']['query']> extends Promise<infer R>
+ ? R
+ : never,
+ ): Row[] {
+ const cols = q.columns();
+ const it = q.iter({});
+ const rows: Row[] = [];
+ for (; it.valid(); it.next()) {
+ const r: Row = {};
+ for (const c of cols) r[c] = it.get(c);
+ rows.push(r);
+ }
+ return rows;
+ }
+}
+
+// "old → new" cell formatting helper for diffed columns (all display strings).
+function fmt(v: unknown): string {
+ return v === null || v === undefined ? '' : String(v);
+}
diff --git a/ui/src/plugins/com.android.ProcessState/process_state_details_panel.ts b/ui/src/plugins/com.android.ProcessState/process_state_details_panel.ts
new file mode 100644
index 0000000..fd918d5
--- /dev/null
+++ b/ui/src/plugins/com.android.ProcessState/process_state_details_panel.ts
@@ -0,0 +1,363 @@
+// Copyright (C) 2026 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 m from 'mithril';
+import type {TrackEventDetailsPanel} from '../../public/details_panel';
+import type {TrackEventSelection} from '../../public/selection';
+import type {Row} from '../../trace_processor/query_result';
+import {DataGrid} from '../../components/widgets/datagrid/datagrid';
+import type {Column} from '../../components/widgets/datagrid/model';
+import type {InMemoryDataSource} from '../../components/widgets/datagrid/in_memory_data_source';
+import {Button} from '../../widgets/button';
+import {Select} from '../../widgets/select';
+import {DetailsShell} from '../../widgets/details_shell';
+import {SplitPanel} from '../../widgets/split_panel';
+import {ProcessGraph} from './process_graph';
+import {gridCard, gridSchema} from './grid_helpers';
+import type {ProcessStateController} from './process_state_controller';
+
+// Process-list columns shown by default (all already display-ready strings/ints
+// from the importer — no enum mapping needed here).
+const DEFAULT_PROC_COLS = [
+ 'pid',
+ 'name',
+ 'uid',
+ 'oom_score',
+ 'proc_state',
+ 'capabilities',
+ 'persistent',
+];
+
+// Above this fraction of the viewport height the drawer is "tall" (close to full
+// page): stack the graph above the details, like HeapDumpExplorer. Below it the
+// drawer is short: lay them out side-by-side.
+const VERTICAL_THRESHOLD = 0.5;
+
+// A diffed cell is encoded as "old → new"; render it amber. Everything else is
+// shown as-is (values already arrive as display strings).
+function deltaRenderer(value: unknown): m.Children {
+ if (typeof value === 'string' && value.includes(' → ')) {
+ return m('span.pf-ps-diff-changed', value);
+ }
+ return value === null || value === undefined ? '' : String(value);
+}
+
+// The whole explorer, in the timeline details panel (there is no separate page).
+// Snapshot nav + diff controls sit in the shell header; the graph and a tabbed
+// detail pane (Current / Process list, all binding tables) fill a SplitPanel
+// whose orientation is responsive — side-by-side while the drawer is short,
+// stacked once it is taller than half the viewport. All state lives in the
+// shared ProcessStateController.
+export class ProcessStateDetailsPanel implements TrackEventDetailsPanel {
+ private readonly c: ProcessStateController;
+ private vertical = false;
+ // Per-grid persisted visible columns.
+ private procColumns?: ReadonlyArray<Column>;
+ private recordColumns: {[key: string]: ReadonlyArray<Column>} = {};
+
+ constructor(controller: ProcessStateController) {
+ this.c = controller;
+ }
+
+ async load(sel: TrackEventSelection) {
+ // The slice id IS the snapshot id; point the shared controller at it.
+ await this.c.ensureLoaded(sel.eventId);
+ }
+
+ // Pick the split orientation from the drawer's height. Stable across the flip
+ // (the box keeps the same height either way), so it doesn't oscillate.
+ private measure(dom: HTMLElement) {
+ const v = dom.clientHeight > window.innerHeight * VERTICAL_THRESHOLD;
+ if (v !== this.vertical) {
+ this.vertical = v;
+ m.redraw();
+ }
+ }
+
+ render() {
+ const c = this.c;
+ if (c.snapshotId === undefined) {
+ return m(DetailsShell, {title: 'Process state'}, m('span', 'Loading…'));
+ }
+ const i = c.snapshots.findIndex((s) => s.id === c.snapshotId);
+ const n = c.snapshots.length;
+ const reason = c.reasonOf(c.snapshotId) ?? 'snapshot';
+ return m(
+ DetailsShell,
+ {
+ title: 'Process state',
+ description: `#${i + 1}/${n} · ${reason} · ${c.processes.length} procs`,
+ buttons: this.renderControls(i, n),
+ className: 'pf-ps-detailpanel',
+ },
+ m(
+ '.pf-ps-splitwrap',
+ {
+ oncreate: (v: m.VnodeDOM) => this.measure(v.dom as HTMLElement),
+ onupdate: (v: m.VnodeDOM) => this.measure(v.dom as HTMLElement),
+ },
+ m(SplitPanel, {
+ direction: this.vertical ? 'vertical' : 'horizontal',
+ initialSplit: {percent: 55},
+ minSize: 120,
+ firstPanel: m(ProcessGraph, {
+ trace: c.trace,
+ processes: c.graphProcesses,
+ bindingsQuery: c.snapshotId,
+ diffNodes: c.diffOn ? c.diffNodes : undefined,
+ diffBaseline:
+ c.diffOn && c.baselineId !== undefined ? c.baselineId : undefined,
+ selectedPids:
+ c.selectedPid !== undefined
+ ? new Set([c.selectedPid])
+ : undefined,
+ selectedEdge: c.selectedEdge,
+ onSelect: (pid: number) => c.select(pid),
+ onEdgeSelect: (e) => c.selectEdge(e),
+ onDeselect: () => c.deselect(),
+ }),
+ secondPanel: m('.pf-ps-bottom', [
+ m('.pf-ps-tabs', [
+ this.tabButton('current', 'Current'),
+ this.tabButton('procs', 'Process list'),
+ ]),
+ c.tab === 'procs'
+ ? m('.pf-ps-tabbody', this.renderProcessList())
+ : m('.pf-ps-tabbody.pf-ps-tabbody--scroll', this.renderCurrent()),
+ ]),
+ }),
+ ),
+ );
+ }
+
+ // Snapshot nav (prev/next, which re-select the matching timeline slice so the
+ // highlight stays in sync) + diff toggle + baseline picker.
+ private renderControls(i: number, n: number): m.Children {
+ const c = this.c;
+ const go = (idx: number) => {
+ const s = c.snapshots[idx];
+ if (s !== undefined) c.goToSnapshot(s.id);
+ };
+ return [
+ m(Button, {
+ icon: 'chevron_left',
+ compact: true,
+ disabled: i <= 0,
+ title: 'Previous snapshot',
+ onclick: () => go(i - 1),
+ }),
+ m(Button, {
+ icon: 'chevron_right',
+ compact: true,
+ disabled: i < 0 || i >= n - 1,
+ title: 'Next snapshot',
+ onclick: () => go(i + 1),
+ }),
+ n >= 2 &&
+ m(Button, {
+ label: 'Diff',
+ icon: 'difference',
+ compact: true,
+ active: c.diffOn,
+ title: 'Highlight what changed vs a baseline snapshot',
+ onclick: () => c.toggleDiff(),
+ }),
+ n >= 2 &&
+ c.diffOn &&
+ m(
+ Select,
+ {
+ title: 'Baseline to compare against',
+ onchange: (e: Event) => {
+ const v = (e.target as HTMLSelectElement).value;
+ if (v === 'prev') c.followPrevBaseline();
+ else c.setBaseline(Number(v));
+ },
+ },
+ [
+ m(
+ 'option',
+ {value: 'prev', selected: c.baselineFollowsPrev},
+ 'vs previous (auto)',
+ ),
+ ...c.snapshots
+ .filter((s) => s.id !== c.snapshotId)
+ .map((s) =>
+ m(
+ 'option',
+ {
+ value: s.id,
+ selected: !c.baselineFollowsPrev && s.id === c.baselineId,
+ },
+ `vs #${c.snapshots.findIndex((x) => x.id === s.id) + 1}`,
+ ),
+ ),
+ ],
+ ),
+ ];
+ }
+
+ private tabButton(tab: 'current' | 'procs', label: string): m.Children {
+ return m(
+ 'button.pf-ps-tab',
+ {
+ className: this.c.tab === tab ? 'pf-ps-tab--on' : '',
+ onclick: () => this.c.setTab(tab),
+ },
+ label,
+ );
+ }
+
+ private renderProcessList(): m.Children {
+ const c = this.c;
+ if (c.procDs === undefined) return m('.pf-ps-none', 'Loading…');
+ const visible =
+ this.procColumns ??
+ DEFAULT_PROC_COLS.filter((x) => c.procCols.includes(x)).map((x) => ({
+ id: x,
+ field: x,
+ }));
+ // In diff mode, the changed columns carry "old → new" strings; colour them.
+ const renderers = c.diffOn
+ ? {
+ oom_score: deltaRenderer,
+ proc_state: deltaRenderer,
+ capabilities: deltaRenderer,
+ }
+ : undefined;
+ return m(DataGrid, {
+ schema: gridSchema(c.procCols, (pid) => c.select(pid), renderers),
+ rootSchema: 'root',
+ data: c.procDs,
+ fillHeight: true,
+ columns: visible,
+ onColumnsChanged: (cols) => {
+ this.procColumns = cols;
+ },
+ });
+ }
+
+ private renderCurrent(): m.Children {
+ const c = this.c;
+ const onPid = (pid: number) => c.select(pid);
+ if (c.selectedEdge) {
+ const e = c.selectedEdge;
+ const nameCol = e.kind === 'provider' ? 'authority' : 'service';
+ return m('.pf-ps-detailpane', [
+ gridCard(
+ e.kind === 'provider'
+ ? 'content-provider binding'
+ : 'service binding',
+ ['client_pid', 'host_pid', 'connections', 'foreground'],
+ c.edgeRows,
+ c.edgeDs,
+ onPid,
+ ),
+ gridCard(
+ e.kind === 'provider' ? 'authorities' : 'services',
+ [nameCol],
+ c.edgeNames,
+ c.edgeNamesDs,
+ onPid,
+ ),
+ ]);
+ }
+ if (c.selectedPid === undefined) {
+ return m(
+ '.pf-ps-none',
+ 'Click a node or an edge in the graph (or a row in Process list).',
+ );
+ }
+ const pid = c.selectedPid;
+ const p = c.graphProcesses.find((r) => Number(r['pid']) === pid);
+ return m('.pf-ps-detailpane', [
+ m('.pf-ps-detail-h', [
+ m('span.pf-ps-detail-title', c.nameOf(pid)),
+ m('span.pf-ps-detail-sub', `pid ${pid} · uid ${p?.['uid'] ?? '—'}`),
+ ]),
+ gridCard(
+ 'process state',
+ ['property', 'value'],
+ c.stateRows,
+ c.stateDs,
+ onPid,
+ ),
+ this.recordCard(
+ 'hosted services',
+ 'hostedSvc',
+ c.hostedSvcCols,
+ c.hostedSvc,
+ c.hostedSvcDs,
+ ),
+ c.hostedProv.length > 0 &&
+ this.recordCard(
+ 'hosted providers',
+ 'hostedProv',
+ c.hostedProvCols,
+ c.hostedProv,
+ c.hostedProvDs,
+ ),
+ gridCard(
+ 'outgoing bindings',
+ ['pid', 'kind', 'name', 'fg', 'n'],
+ c.outAll,
+ c.outDs,
+ onPid,
+ ),
+ gridCard(
+ 'incoming bindings',
+ ['pid', 'kind', 'name', 'fg', 'n'],
+ c.inAll,
+ c.inDs,
+ onPid,
+ ),
+ gridCard(
+ 'self bindings',
+ ['kind', 'name', 'fg', 'n'],
+ c.selfAll,
+ c.selfDs,
+ onPid,
+ ),
+ ]);
+ }
+
+ private recordCard(
+ title: string,
+ key: string,
+ allCols: string[],
+ rows: ReadonlyArray<Row>,
+ ds?: InMemoryDataSource,
+ ): m.Children {
+ if (!rows.length || ds === undefined) {
+ return m('.pf-ps-card', [
+ m('.pf-ps-card-h', title),
+ m('.pf-ps-card-b', m('.pf-ps-none', '— none —')),
+ ]);
+ }
+ const visible =
+ this.recordColumns[key] ?? allCols.map((x) => ({id: x, field: x}));
+ return m('.pf-ps-card', [
+ m('.pf-ps-card-h', title),
+ m(DataGrid, {
+ schema: gridSchema(allCols, (pid) => this.c.select(pid)),
+ rootSchema: 'root',
+ data: ds,
+ columns: visible,
+ onColumnsChanged: (cols) => {
+ this.recordColumns[key] = cols;
+ },
+ }),
+ ]);
+ }
+}
diff --git a/ui/src/plugins/com.android.ProcessState/process_state_track.ts b/ui/src/plugins/com.android.ProcessState/process_state_track.ts
new file mode 100644
index 0000000..80d1828
--- /dev/null
+++ b/ui/src/plugins/com.android.ProcessState/process_state_track.ts
@@ -0,0 +1,57 @@
+// Copyright (C) 2026 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 {materialColorScheme} from '../../components/colorizer';
+import {SliceTrack} from '../../components/tracks/slice_track';
+import type {Trace} from '../../public/trace';
+import {SourceDataset} from '../../trace_processor/dataset';
+import {LONG, NUM, STR} from '../../trace_processor/query_result';
+import {ProcessStateDetailsPanel} from './process_state_details_panel';
+import type {ProcessStateController} from './process_state_controller';
+
+// A single timeline track: one slice per snapshot, spanning from its capture
+// time to the next snapshot (the last extends to the trace end) so a slice is a
+// selectable region of "this graph state". The slice name is the oom-adj reason
+// (already resolved to a name by the importer; NULL for a one-shot dumpsys
+// snapshot, shown as "SNAPSHOT"); the per-importance-tier process counts are the
+// nested counter tracks under this one. Selecting a slice opens the
+// ProcessStateDetailsPanel. The slice id IS the snapshot id, so selection maps
+// straight onto _ps_snapshot.
+export function createProcessStateTrack(
+ trace: Trace,
+ uri: string,
+ controller: ProcessStateController,
+) {
+ // dur (the gap-free span to the next snapshot) and name (the oom-adj reason,
+ // "SNAPSHOT" for a one-shot dumpsys capture) are precomputed and materialized
+ // in _ps_snapshot, so this is a plain indexed read — no per-fetch window
+ // function recompute. The per-importance-tier process counts are the nested
+ // counter tracks, not this slice's name.
+ const src = `SELECT id, ts, dur, 0 AS depth, name FROM _ps_snapshot`;
+
+ // One panel instance, reused across selections so mithril patches in place.
+ // It reads the shared controller so its selection matches the full page.
+ const panel = new ProcessStateDetailsPanel(controller);
+
+ return SliceTrack.create({
+ trace,
+ uri,
+ dataset: new SourceDataset({
+ schema: {id: NUM, ts: LONG, dur: LONG, name: STR, depth: NUM},
+ src,
+ }),
+ detailsPanel: () => panel,
+ colorizer: (row) => materialColorScheme(row.name),
+ });
+}
diff --git a/ui/src/plugins/com.android.ProcessState/relations.ts b/ui/src/plugins/com.android.ProcessState/relations.ts
new file mode 100644
index 0000000..d5e66e2
--- /dev/null
+++ b/ui/src/plugins/com.android.ProcessState/relations.ts
@@ -0,0 +1,99 @@
+// Copyright (C) 2026 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.
+
+// Projects the `__intrinsic_android_process_state_*` intrinsic tables — filled
+// by the process_state_importer trace_processor plugin from ProcessStateSnapshot
+// packets (the data behind `dumpsys activity`) — onto the stable `_ps_*`
+// relations the explorer reads. Enum fields (proc_state, capabilities, reason)
+// already arrive as resolved names from the importer, so this is a plain rename
+// projection with no enum logic of its own.
+
+import type {Engine} from '../../trace_processor/engine';
+import {NUM} from '../../trace_processor/query_result';
+
+// Only _ps_snapshot is materialized; the rest are plain views. This split is
+// deliberate and measured. The explorer reads one snapshot at a time (select a
+// snapshot -> its processes/services/bindings), and the intrinsic tables filter
+// on snapshot_id in ~a few ms, so views over them are perfectly fine for a
+// single-snapshot read. The one exception is the timeline slice's gap-free `dur`
+// (a LEAD() window): a window function blocks predicate push-down, so fetching a
+// single slice by id would recompute dur for *every* snapshot. On a long trace
+// (tens of thousands of snapshots) that one query alone took ~110s. Precomputing
+// it once into a table makes the slice fetch instant; everything else stays a
+// cheap view.
+const SETUP_SQL: ReadonlyArray<string> = [
+ // Snapshots, with the gap-free slice span (dur) and display name precomputed
+ // once. Filtered by id (the table's primary key, already fast), so no index is
+ // needed; the point is just to evaluate the LEAD() window a single time.
+ `CREATE PERFETTO TABLE _ps_snapshot AS
+ SELECT id, ts,
+ IFNULL(LEAD(ts) OVER (ORDER BY ts) - ts,
+ (SELECT end_ts FROM trace_bounds) - ts) AS dur,
+ reason,
+ UPPER(COALESCE(reason, 'snapshot')) AS name
+ FROM __intrinsic_android_process_state_snapshot`,
+
+ // The track-event reconstruction path has no process name (the
+ // process_state_changed_event doesn't carry one), so fall back to the name
+ // from trace_processor's `process` table (process-tree / ftrace) for the
+ // process live at the snapshot's timestamp. The one-shot dumpsys snapshot
+ // already has names, so its rows are unaffected. This subquery only ever runs
+ // for the ~processes of the single selected snapshot, so a view is fine.
+ `CREATE PERFETTO VIEW _ps_process AS
+ SELECT pr.snapshot_id, pr.pid, pr.uid,
+ COALESCE(pr.name, (
+ SELECT p.name FROM process p
+ WHERE p.pid = pr.pid
+ AND (p.start_ts IS NULL OR p.start_ts <= s.ts)
+ AND (p.end_ts IS NULL OR p.end_ts >= s.ts)
+ LIMIT 1)) AS name,
+ pr.oom_score, pr.proc_state, pr.capabilities, pr.persistent
+ FROM __intrinsic_android_process_state_process pr
+ JOIN __intrinsic_android_process_state_snapshot s ON s.id = pr.snapshot_id`,
+
+ `CREATE PERFETTO VIEW _ps_service AS
+ SELECT snapshot_id, svc_id AS service_id, owning_pid, name
+ FROM __intrinsic_android_process_state_service`,
+
+ `CREATE PERFETTO VIEW _ps_service_binding AS
+ SELECT snapshot_id, service_id, client_pid, foreground
+ FROM __intrinsic_android_process_state_service_binding`,
+
+ `CREATE PERFETTO VIEW _ps_provider AS
+ SELECT snapshot_id, provider_id, owning_pid, authority
+ FROM __intrinsic_android_process_state_provider`,
+
+ `CREATE PERFETTO VIEW _ps_provider_binding AS
+ SELECT snapshot_id, provider_id, client_pid, stable
+ FROM __intrinsic_android_process_state_provider_binding`,
+];
+
+/**
+ * Builds the `_ps_*` relations the explorer reads. Returns the number of
+ * process rows available (0 → no process-state data in this trace; the plugin
+ * should stay inactive).
+ */
+export async function buildProcessState(engine: Engine): Promise<number> {
+ try {
+ for (const sql of SETUP_SQL) {
+ await engine.query(sql);
+ }
+ } catch {
+ // The intrinsic tables don't exist (a trace_processor without the
+ // process_state_importer plugin) — leave the plugin inactive.
+ return 0;
+ }
+ const res = await engine.query(`SELECT count(*) AS n FROM _ps_process`);
+ return res.iter({n: NUM}).n;
+}
diff --git a/ui/src/plugins/dev.perfetto.TraceProcessorTrack/trace_processor_slice_track.ts b/ui/src/plugins/dev.perfetto.TraceProcessorTrack/trace_processor_slice_track.ts
index 6497107..d53e368 100644
--- a/ui/src/plugins/dev.perfetto.TraceProcessorTrack/trace_processor_slice_track.ts
+++ b/ui/src/plugins/dev.perfetto.TraceProcessorTrack/trace_processor_slice_track.ts
@@ -50,7 +50,6 @@
depth: NUM,
thread_dur: LONG_NULL,
category: STR_NULL,
- correlation_id: STR_NULL,
arg_set_id: NUM_NULL,
parent_id: NUM_NULL,
track_id: NUM,
@@ -89,11 +88,6 @@
? (row) => detailsPanel(row)
: () => new ThreadSliceDetailsPanel(trace),
colorizer: (row) => {
- if (row.correlation_id) {
- return getColorForSlice(row.correlation_id, {
- stripTrailingDigits: false,
- });
- }
if (row.name) {
return getColorForSlice(row.name);
}
@@ -122,7 +116,6 @@
thread_dur: 'thread_dur',
track_id: 'track_id',
category: 'category',
- correlation_id: "extract_arg(arg_set_id, 'correlation_id')",
arg_set_id: 'arg_set_id',
parent_id: 'parent_id',
},
@@ -167,7 +160,6 @@
thread_dur: 'thread_dur',
track_id: 'track_id',
category: 'category',
- correlation_id: "extract_arg(arg_set_id, 'correlation_id')",
arg_set_id: 'arg_set_id',
parent_id: 'parent_id',
},