Maintain property collapse/expand across frames.

Collapse state for a particular property maintained across frames for a specific selected item.

Bug: b/315444345
Test: npm run test:unit:ci
Change-Id: I0c98b51e4f65484609092a1329fb336e38acd714
diff --git a/tools/winscope/src/viewers/components/hierarchy_component.ts b/tools/winscope/src/viewers/components/hierarchy_component.ts
index d902200..f758039 100644
--- a/tools/winscope/src/viewers/components/hierarchy_component.ts
+++ b/tools/winscope/src/viewers/components/hierarchy_component.ts
@@ -70,7 +70,7 @@
         [item]="tree"
         [dependencies]="dependencies"
         [store]="store"
-        [useGlobalCollapsedState]="true"
+        [useStoredExpandedState]="true"
         [itemsClickable]="true"
         [highlightedItem]="highlightedItem"
         [pinnedItems]="pinnedItems"
diff --git a/tools/winscope/src/viewers/components/properties_component.ts b/tools/winscope/src/viewers/components/properties_component.ts
index 569843f..e4c2b78 100644
--- a/tools/winscope/src/viewers/components/properties_component.ts
+++ b/tools/winscope/src/viewers/components/properties_component.ts
@@ -14,6 +14,7 @@
  * limitations under the License.
  */
 import {Component, ElementRef, Inject, Input} from '@angular/core';
+import {PersistentStore} from 'common/persistent_store';
 import {TraceTreeNode} from 'trace/trace_tree_node';
 import {TraceType, ViewNode} from 'trace/trace_type';
 import {PropertiesTreeNode, Terminal} from 'viewers/common/ui_tree_utils';
@@ -70,11 +71,13 @@
           *ngIf="objectKeys(propertiesTree).length > 0 && !showViewCaptureFormat()"
           [item]="propertiesTree"
           [showNode]="showNode"
+          [store]="store"
+          [useStoredExpandedState]="true"
+          [isExpandedByDefault]="false"
           [itemsClickable]="true"
           [highlightedItem]="highlightedProperty"
           (highlightedChange)="onHighlightedPropertyChange($event)"
-          [isLeaf]="isLeaf"
-          [isAlwaysCollapsed]="true"></tree-view>
+          [isLeaf]="isLeaf"></tree-view>
       </div>
     </div>
   `,
@@ -136,6 +139,7 @@
   @Input() displayPropertyGroups = false;
   @Input() isProtoDump = false;
   @Input() traceType: TraceType | undefined;
+  @Input() store!: PersistentStore;
 
   constructor(@Inject(ElementRef) private elementRef: ElementRef) {}
 
diff --git a/tools/winscope/src/viewers/components/properties_component_test.ts b/tools/winscope/src/viewers/components/properties_component_test.ts
index f118575..05c6a89 100644
--- a/tools/winscope/src/viewers/components/properties_component_test.ts
+++ b/tools/winscope/src/viewers/components/properties_component_test.ts
@@ -22,6 +22,7 @@
 import {MatFormFieldModule} from '@angular/material/form-field';
 import {MatInputModule} from '@angular/material/input';
 import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
+import {PersistentStore} from 'common/persistent_store';
 import {PropertiesComponent} from './properties_component';
 import {SurfaceFlingerPropertyGroupsComponent} from './surface_flinger_property_groups_component';
 import {TreeComponent} from './tree_component';
@@ -52,6 +53,7 @@
     component = fixture.componentInstance;
     htmlElement = fixture.nativeElement;
 
+    component.store = new PersistentStore();
     component.userOptions = {
       showDiff: {
         name: 'Show diff',
diff --git a/tools/winscope/src/viewers/components/tree_component.ts b/tools/winscope/src/viewers/components/tree_component.ts
index d662ce7..09af453 100644
--- a/tools/winscope/src/viewers/components/tree_component.ts
+++ b/tools/winscope/src/viewers/components/tree_component.ts
@@ -21,7 +21,9 @@
   Inject,
   Input,
   Output,
+  SimpleChanges,
 } from '@angular/core';
+import {assertDefined} from 'common/assert_utils';
 import {PersistentStore} from 'common/persistent_store';
 import {TraceType} from 'trace/trace_type';
 import {HierarchyTreeNode, UiTreeNode, UiTreeUtils} from 'viewers/common/ui_tree_utils';
@@ -41,13 +43,12 @@
       [class.child-selected]="hasSelectedChild()"
       [class.hover]="nodeHover"
       [class.childHover]="childHover"
-      [isAlwaysCollapsed]="isAlwaysCollapsed"
       [class]="diffClass(item)"
       [style]="nodeOffsetStyle()"
       [item]="item"
       [flattened]="isFlattened"
       [isLeaf]="isLeaf(this.item)"
-      [isCollapsed]="isAlwaysCollapsed ?? isCollapsed()"
+      [isExpanded]="isExpanded()"
       [hasChildren]="hasChildren()"
       [isPinned]="isPinned()"
       [isSelected]="isHighlighted(item, highlightedItem)"
@@ -60,7 +61,7 @@
       *ngIf="hasChildren()"
       class="children"
       [class.flattened]="isFlattened"
-      [hidden]="!isCollapsed()">
+      [hidden]="!isExpanded()">
       <tree-view
         *ngFor="let child of children(); trackBy: childTrackById"
         class="childrenTree"
@@ -70,7 +71,7 @@
         [isLeaf]="isLeaf"
         [dependencies]="dependencies"
         [isFlattened]="isFlattened"
-        [useGlobalCollapsedState]="useGlobalCollapsedState"
+        [useStoredExpandedState]="useStoredExpandedState"
         [initialDepth]="initialDepth + 1"
         [highlightedItem]="highlightedItem"
         [pinnedItems]="pinnedItems"
@@ -94,14 +95,16 @@
   @Input() dependencies: TraceType[] = [];
 
   @Input() item?: UiTreeNode;
-  @Input() store!: PersistentStore;
+  @Input() store?: PersistentStore;
   @Input() isFlattened? = false;
   @Input() initialDepth = 0;
   @Input() highlightedItem: string = '';
-  @Input() pinnedItems?: HierarchyTreeNode[] = [];
+  @Input() pinnedItems: HierarchyTreeNode[] = [];
   @Input() itemsClickable?: boolean;
-  @Input() useGlobalCollapsedState?: boolean;
-  @Input() isAlwaysCollapsed?: boolean;
+
+  // Conditionally use stored states. Some traces (e.g. transactions) do not provide items with the "stable id" field needed to search values in the storage.
+  @Input() useStoredExpandedState = false;
+
   @Input() showNode = (item: UiTreeNode) => true;
   @Input() isLeaf = (item?: UiTreeNode) => {
     return !item || !item.children || item.children.length === 0;
@@ -113,13 +116,14 @@
   @Output() hoverStart = new EventEmitter<void>();
   @Output() hoverEnd = new EventEmitter<void>();
 
-  isCollapsedByDefault = true;
-  localCollapsedState = this.isCollapsedByDefault;
+  localExpandedState = true;
   nodeHover = false;
   childHover = false;
   readonly levelOffset = 24;
   nodeElement: HTMLElement;
 
+  private storeKeyExpandedState = '';
+
   childTrackById(index: number, child: UiTreeNode): string {
     if (child.stableId !== undefined) {
       return child.stableId;
@@ -138,13 +142,18 @@
     this.nodeElement?.addEventListener('mouseleave', this.nodeMouseLeaveEventListener);
   }
 
-  ngOnInit() {
-    if (this.isCollapsedByDefault) {
-      this.setCollapseValue(this.isCollapsedByDefault);
+  ngOnChanges(changes: SimpleChanges) {
+    if (changes['item']) {
+      this.storeKeyExpandedState = `treeView.expandedState.item.${this.dependencies}.${this.item?.stableId}`;
+      if (this.store) {
+        this.setExpandedValue(
+          true,
+          assertDefined(this.store).get(this.storeKeyExpandedState) === null
+        );
+      } else {
+        this.setExpandedValue(true);
+      }
     }
-  }
-
-  ngOnChanges() {
     if (
       this.item instanceof HierarchyTreeNode &&
       UiTreeUtils.isHighlighted(this.item, this.highlightedItem)
@@ -183,12 +192,6 @@
     };
   }
 
-  private updateHighlightedItem() {
-    if (this.item?.stableId) {
-      this.highlightedChange.emit(`${this.item.stableId}`);
-    }
-  }
-
   isPinned() {
     if (this.item instanceof HierarchyTreeNode) {
       return this.pinnedItems?.map((item) => `${item.stableId}`).includes(`${this.item.stableId}`);
@@ -213,25 +216,23 @@
   }
 
   toggleTree() {
-    this.setCollapseValue(!this.isCollapsed());
+    this.setExpandedValue(!this.isExpanded());
   }
 
   expandTree() {
-    this.setCollapseValue(true);
+    this.setExpandedValue(true);
   }
 
-  isCollapsed() {
-    if (this.isAlwaysCollapsed || this.isLeaf(this.item)) {
+  isExpanded() {
+    if (this.isLeaf(this.item)) {
       return true;
     }
 
-    if (this.useGlobalCollapsedState) {
-      return (
-        this.store.get(`collapsedState.item.${this.dependencies}.${this.item?.stableId}`) ===
-          'true' ?? this.isCollapsedByDefault
-      );
+    if (this.useStoredExpandedState) {
+      return assertDefined(this.store).get(this.storeKeyExpandedState) === 'true' ?? false;
     }
-    return this.localCollapsedState;
+
+    return this.localExpandedState;
   }
 
   children(): UiTreeNode[] {
@@ -251,7 +252,7 @@
     if (!this.hasChildren()) {
       return false;
     }
-    for (const child of this.item!.children!) {
+    for (const child of assertDefined(this.item?.children)) {
       if (child.stableId && this.highlightedItem === child.stableId) {
         return true;
       }
@@ -259,14 +260,17 @@
     return false;
   }
 
-  private setCollapseValue(isCollapsed: boolean) {
-    if (this.useGlobalCollapsedState) {
-      this.store.add(
-        `collapsedState.item.${this.dependencies}.${this.item?.stableId}`,
-        `${isCollapsed}`
-      );
+  private updateHighlightedItem() {
+    if (this.item?.stableId) {
+      this.highlightedChange.emit(`${this.item.stableId}`);
+    }
+  }
+
+  private setExpandedValue(isExpanded: boolean, shouldUpdateStoredState = true) {
+    if (this.useStoredExpandedState && shouldUpdateStoredState) {
+      assertDefined(this.store).add(this.storeKeyExpandedState, `${isExpanded}`);
     } else {
-      this.localCollapsedState = isCollapsed;
+      this.localExpandedState = isExpanded;
     }
   }
 
diff --git a/tools/winscope/src/viewers/components/tree_component_test.ts b/tools/winscope/src/viewers/components/tree_component_test.ts
index 55a5b7c..63c1f2d 100644
--- a/tools/winscope/src/viewers/components/tree_component_test.ts
+++ b/tools/winscope/src/viewers/components/tree_component_test.ts
@@ -13,7 +13,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {Component, NO_ERRORS_SCHEMA, ViewChild} from '@angular/core';
+import {Component, NO_ERRORS_SCHEMA, QueryList, ViewChildren} from '@angular/core';
 import {ComponentFixture, ComponentFixtureAutoDetect, TestBed} from '@angular/core/testing';
 import {assertDefined} from 'common/assert_utils';
 import {PersistentStore} from 'common/persistent_store';
@@ -25,6 +25,7 @@
   let fixture: ComponentFixture<TestHostComponent>;
   let component: TestHostComponent;
   let htmlElement: HTMLElement;
+  const children = makeTreeNodeChildren();
 
   beforeEach(async () => {
     await TestBed.configureTestingModule({
@@ -48,17 +49,17 @@
   });
 
   it('can identify if a parent node has a selected child', () => {
-    expect(component.treeComponent.hasSelectedChild()).toBeFalse();
-    component.highlightedItem = 'child3';
+    expect(component.treeComponents.first.hasSelectedChild()).toBeFalse();
+    component.highlightedItem = '1child3';
     fixture.detectChanges();
-    expect(component.treeComponent.hasSelectedChild()).toBeTrue();
+    expect(component.treeComponents.first.hasSelectedChild()).toBeTrue();
   });
 
   it('highlights item upon node click', () => {
     const treeNode = htmlElement.querySelector('tree-node');
     expect(treeNode).toBeTruthy();
 
-    const spy = spyOn(component.treeComponent.highlightedChange, 'emit');
+    const spy = spyOn(component.treeComponents.first.highlightedChange, 'emit');
     (treeNode as HTMLButtonElement).dispatchEvent(new MouseEvent('click', {detail: 1}));
     fixture.detectChanges();
     expect(spy).toHaveBeenCalled();
@@ -68,32 +69,68 @@
     const treeNode = htmlElement.querySelector('tree-node');
     expect(treeNode).toBeTruthy();
 
-    const currCollapseValue = component.treeComponent.localCollapsedState;
+    const currCollapseValue = component.treeComponents.first.localExpandedState;
     (treeNode as HTMLButtonElement).dispatchEvent(new MouseEvent('click', {detail: 2}));
     fixture.detectChanges();
-    expect(!currCollapseValue).toBe(component.treeComponent.localCollapsedState);
+    expect(!currCollapseValue).toBe(component.treeComponents.first.localExpandedState);
   });
 
   it('scrolls selected node into view if out of view', async () => {
-    const treeNode = assertDefined(htmlElement.querySelector(`#nodechild50`));
+    const treeNode = assertDefined(htmlElement.querySelector('#node1child50'));
     const spy = spyOn(treeNode, 'scrollIntoView');
-    component.highlightedItem = 'child50';
+    component.highlightedItem = '1child50';
     fixture.detectChanges();
     expect(spy).toHaveBeenCalled();
   });
 
   it('does not scroll selected element if already in view', () => {
-    const treeNode = assertDefined(htmlElement.querySelector(`#nodechild2`));
+    const treeNode = assertDefined(htmlElement.querySelector('#node1child2'));
     const spy = spyOn(treeNode, 'scrollIntoView');
-    component.highlightedItem = 'child2';
+    component.highlightedItem = '1child2';
     fixture.detectChanges();
     expect(spy).not.toHaveBeenCalled();
   });
 
+  it('sets initial expanded state to true by default', () => {
+    const tree = assertDefined(component.treeComponents.get(1));
+    fixture.detectChanges();
+    expect(tree.isExpanded()).toBeTrue();
+  });
+
+  it('does not initially set expanded state to true if already exists in store', () => {
+    // item1 expanded by default
+    const tree = assertDefined(component.treeComponents.get(1));
+    fixture.detectChanges();
+    expect(tree.isExpanded()).toBeTrue();
+
+    // item1 collapsed
+    tree.toggleTree();
+    fixture.detectChanges();
+    expect(tree.isExpanded()).toBeFalse();
+
+    // item0 expanded by default
+    component.itemWithStoredExpandedState = component.item0;
+    fixture.detectChanges();
+    expect(tree.isExpanded()).toBeTrue();
+
+    // item0 collapsed state retained
+    component.itemWithStoredExpandedState = component.item1;
+    fixture.detectChanges();
+    expect(tree.isExpanded()).toBeFalse();
+  });
+
   function makeTreeNodeChildren(): UiTreeNode[] {
     const children = [];
     for (let i = 0; i < 60; i++) {
-      children.push({kind: `${i}`, stableId: `child${i}`, name: `Child${i}`});
+      const child: UiTreeNode = {
+        kind: `${i}`,
+        stableId: `1child${i}`,
+        name: `Child${i}`,
+        children: [
+          {kind: `${i}`, stableId: `1innerChild${i}`, name: `InnerChild${i}`, children: []},
+        ],
+      };
+      children.push(child);
     }
     return children;
   }
@@ -102,26 +139,57 @@
     selector: 'host-component',
     template: `
       <tree-view
-        [item]="item"
+        [item]="item0"
         [store]="store"
         [isFlattened]="false"
         [isPinned]="false"
         [highlightedItem]="highlightedItem"
         [itemsClickable]="true"></tree-view>
+
+      <tree-view
+        [item]="itemWithStoredExpandedState"
+        [store]="store"
+        [isFlattened]="false"
+        [isPinned]="false"
+        [highlightedItem]="highlightedItem"
+        [useStoredExpandedState]="true"
+        [itemsClickable]="true"></tree-view>
     `,
   })
   class TestHostComponent {
-    item: UiTreeNode = {
+    item0: UiTreeNode = {
+      simplifyNames: false,
+      kind: 'entry',
+      name: 'LayerTraceEntry',
+      stableId: 'LayerTraceEntry 1',
+      children,
+    };
+
+    item1: UiTreeNode = {
       simplifyNames: false,
       kind: 'entry',
       name: 'LayerTraceEntry',
       stableId: 'LayerTraceEntry 2',
-      children: makeTreeNodeChildren(),
+      children: [
+        {
+          kind: '1',
+          stableId: '2child1',
+          name: 'Child1',
+          children: [],
+        },
+      ],
     };
+
+    itemWithStoredExpandedState = this.item1;
+
     store = new PersistentStore();
     highlightedItem = '';
 
-    @ViewChild(TreeComponent)
-    treeComponent!: TreeComponent;
+    constructor() {
+      localStorage.clear();
+    }
+
+    @ViewChildren(TreeComponent)
+    treeComponents!: QueryList<TreeComponent>;
   }
 });
diff --git a/tools/winscope/src/viewers/components/tree_node_component.ts b/tools/winscope/src/viewers/components/tree_node_component.ts
index a2e3e6b..520842d 100644
--- a/tools/winscope/src/viewers/components/tree_node_component.ts
+++ b/tools/winscope/src/viewers/components/tree_node_component.ts
@@ -22,7 +22,7 @@
   template: `
     <button *ngIf="showChevron()" class="icon-button toggle-tree-btn" (click)="toggleTree($event)">
       <mat-icon>
-        {{ isCollapsed ? 'arrow_drop_down' : 'chevron_right' }}
+        {{ isExpanded ? 'arrow_drop_down' : 'chevron_right' }}
       </mat-icon>
     </button>
 
@@ -44,7 +44,7 @@
     </div>
 
     <button
-      *ngIf="hasChildren && !isCollapsed"
+      *ngIf="hasChildren && !isExpanded"
       class="icon-button expand-tree-btn"
       [class]="collapseDiffClass"
       (click)="expandTree($event)">
@@ -57,11 +57,10 @@
   @Input() item!: UiTreeNode;
   @Input() isLeaf?: boolean;
   @Input() flattened?: boolean;
-  @Input() isCollapsed?: boolean;
+  @Input() isExpanded?: boolean;
   @Input() hasChildren?: boolean = false;
   @Input() isPinned?: boolean = false;
   @Input() isInPinnedSection?: boolean = false;
-  @Input() isAlwaysCollapsed?: boolean;
   @Input() isSelected?: boolean = false;
 
   @Output() toggleTreeChange = new EventEmitter<void>();
@@ -103,10 +102,8 @@
   }
 
   toggleTree(event: MouseEvent) {
-    if (!this.isAlwaysCollapsed) {
-      event.stopPropagation();
-      this.toggleTreeChange.emit();
-    }
+    event.stopPropagation();
+    this.toggleTreeChange.emit();
   }
 
   showChevron() {
@@ -128,7 +125,7 @@
   }
 
   updateCollapseDiffClass() {
-    if (this.isCollapsed) {
+    if (this.isExpanded) {
       return '';
     }
 
diff --git a/tools/winscope/src/viewers/components/tree_node_component_test.ts b/tools/winscope/src/viewers/components/tree_node_component_test.ts
index c80e697..b6f8ca7 100644
--- a/tools/winscope/src/viewers/components/tree_node_component_test.ts
+++ b/tools/winscope/src/viewers/components/tree_node_component_test.ts
@@ -83,7 +83,7 @@
     template: `
       <tree-node
         [item]="item"
-        [isCollapsed]="false"
+        [isExpanded]="false"
         [isPinned]="false"
         [isInPinnedSection]="false"
         [hasChildren]="true"
diff --git a/tools/winscope/src/viewers/components/viewer_input_method_component.ts b/tools/winscope/src/viewers/components/viewer_input_method_component.ts
index 574d791..3f20f50 100644
--- a/tools/winscope/src/viewers/components/viewer_input_method_component.ts
+++ b/tools/winscope/src/viewers/components/viewer_input_method_component.ts
@@ -48,6 +48,7 @@
 
       <properties-view
         class="properties-view"
+        [store]="store"
         [userOptions]="inputData?.propertiesUserOptions ?? {}"
         [propertiesTree]="inputData?.propertiesTree ?? {}"></properties-view>
     </div>
diff --git a/tools/winscope/src/viewers/viewer_surface_flinger/viewer_surface_flinger_component.ts b/tools/winscope/src/viewers/viewer_surface_flinger/viewer_surface_flinger_component.ts
index 21858bc..4712bce 100644
--- a/tools/winscope/src/viewers/viewer_surface_flinger/viewer_surface_flinger_component.ts
+++ b/tools/winscope/src/viewers/viewer_surface_flinger/viewer_surface_flinger_component.ts
@@ -47,6 +47,7 @@
         [highlightedProperty]="inputData?.highlightedProperty ?? ''"
         [selectedItem]="inputData?.selectedLayer ?? {}"
         [traceType]="${TraceType.SURFACE_FLINGER}"
+        [store]="store"
         [displayPropertyGroups]="inputData?.displayPropertyGroups"
         [isProtoDump]="true"></properties-view>
     </div>
diff --git a/tools/winscope/src/viewers/viewer_transitions/viewer_transitions_component.ts b/tools/winscope/src/viewers/viewer_transitions/viewer_transitions_component.ts
index fdf3b4c..58b7433 100644
--- a/tools/winscope/src/viewers/viewer_transitions/viewer_transitions_component.ts
+++ b/tools/winscope/src/viewers/viewer_transitions/viewer_transitions_component.ts
@@ -104,8 +104,7 @@
         <tree-view
           [item]="uiData.selectedTransitionPropertiesTree"
           [showNode]="showNode"
-          [isLeaf]="isLeaf"
-          [isAlwaysCollapsed]="true">
+          [isLeaf]="isLeaf">
         </tree-view>
         <div *ngIf="!uiData.selectedTransitionPropertiesTree">No selected transition.</div>
       </div>
diff --git a/tools/winscope/src/viewers/viewer_view_capture/viewer_view_capture_component.ts b/tools/winscope/src/viewers/viewer_view_capture/viewer_view_capture_component.ts
index 9e4935c..e00d690 100644
--- a/tools/winscope/src/viewers/viewer_view_capture/viewer_view_capture_component.ts
+++ b/tools/winscope/src/viewers/viewer_view_capture/viewer_view_capture_component.ts
@@ -52,6 +52,7 @@
         [propertiesTree]="inputData?.propertiesTree ?? {}"
         [selectedItem]="inputData?.selectedViewNode ?? null"
         [traceType]="${TraceType.VIEW_CAPTURE}"
+        [store]="store"
         [isProtoDump]="false">
       </properties-view>
     </div>
diff --git a/tools/winscope/src/viewers/viewer_window_manager/viewer_window_manager_component.ts b/tools/winscope/src/viewers/viewer_window_manager/viewer_window_manager_component.ts
index 907f6aa..3d558f8 100644
--- a/tools/winscope/src/viewers/viewer_window_manager/viewer_window_manager_component.ts
+++ b/tools/winscope/src/viewers/viewer_window_manager/viewer_window_manager_component.ts
@@ -44,6 +44,7 @@
         [userOptions]="inputData?.propertiesUserOptions ?? {}"
         [propertiesTree]="inputData?.propertiesTree ?? {}"
         [highlightedProperty]="inputData?.highlightedProperty ?? ''"
+        [store]="store"
         [isProtoDump]="true"></properties-view>
     </div>
   `,