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',
       },