Add share button option to Winscope toolbar

Test: npm run test:unit:ci
Bug: 440556219
Change-Id: Ic152419b1a38796656f2994fbfcaf058ec9d9655
diff --git a/tools/winscope/src/app/components/app_component.ts b/tools/winscope/src/app/components/app_component.ts
index fa0fbb1..0d47c26 100644
--- a/tools/winscope/src/app/components/app_component.ts
+++ b/tools/winscope/src/app/components/app_component.ts
@@ -98,6 +98,11 @@
   WarningDialogData,
   WarningDialogResult,
 } from './warning_dialog_component';
+import {MatCheckboxModule} from '@angular/material/checkbox';
+import {MatMenuModule} from '@angular/material/menu';
+import {ClipboardModule} from '@angular/cdk/clipboard';
+import {FormsModule} from '@angular/forms';
+import {RequestData} from 'cross_tool/g3_proxy';
 
 /**
  * The root component of the Winscope app.
@@ -107,13 +112,17 @@
   encapsulation: ViewEncapsulation.None,
   standalone: true,
   imports: [
+    ClipboardModule,
     CommonModule,
+    FormsModule,
     MatToolbarModule,
     MatButtonModule,
     MatTooltipModule,
     MatIconModule,
     MatFormFieldModule,
     MatInputModule,
+    MatMenuModule,
+    MatCheckboxModule,
     ReactiveFormsModule,
     MatProgressBarModule,
     MatDividerModule,
@@ -281,6 +290,30 @@
         @if (isInsideWinscopeProxyFrame()) {
           <button
             mat-icon-button
+            matTooltip="Share"
+            class="share-btn"
+            [matMenuTriggerFor]="shareMenu">
+            <mat-icon>share</mat-icon>
+          </button>
+          <mat-menu #shareMenu="matMenu" (click)="$event.stopPropagation()">
+            <div class="share-menu-content" (click)="$event.stopPropagation()">
+              <div class="share-link-container">
+                <mat-form-field class="share-link-field" subscriptSizing="dynamic">
+                  <mat-label>Shareable link</mat-label>
+                  <input matInput readonly [value]="generatedShareLink">
+                </mat-form-field>
+
+                <button mat-icon-button [cdkCopyToClipboard]="generatedShareLink" matTooltip="Copy link" [disabled]="!generatedShareLink">
+                  <mat-icon>content_copy</mat-icon>
+                </button>
+              </div>
+            </div>
+          </mat-menu>
+        }
+
+        @if (isInsideWinscopeProxyFrame()) {
+          <button
+            mat-icon-button
             class="iframe-settings"
             matTootltip="Settings"
             (click)="openSettings()">
@@ -448,6 +481,24 @@
         flex-grow: 1;
         margin: auto;
       }
+
+      .share-menu-content {
+        padding: 16px;
+        display: flex;
+        flex-direction: column;
+      }
+      .share-menu-content mat-checkbox {
+        margin-bottom: 8px;
+      }
+      .share-link-container {
+        display: flex;
+        align-items: center;
+        gap: 8px;
+        margin-top: 8px;
+      }
+      .share-link-field {
+        flex-grow: 1;
+      }
     `,
     iconDividerStyle,
   ],
@@ -465,6 +516,8 @@
   persistentStore = new PersistentStore();
   viewers: Viewer[] = [];
 
+  generatedShareLink = '';
+
   isDarkModeOn = false;
   changeDetectorRef: ChangeDetectorRef;
   tracePipeline: TracePipeline;
@@ -514,6 +567,8 @@
       new PersistentStore(),
     );
 
+    this.updateShareLink();
+
     const storeDarkMode = this.persistentStore.get('dark-mode');
     const prefersDarkQuery = window.matchMedia?.(
       '(prefers-color-scheme: dark)',
@@ -778,11 +833,20 @@
     }
   }
 
-  getReportedParentOrigin() {
+  getReportedParentOrigin(): string | null {
     const urlParams = new URLSearchParams(window.location.search);
     return urlParams.get('parentOrigin');
   }
 
+  getReportedRequest(): RequestData | undefined {
+    const urlParams = new URLSearchParams(window.location.search);
+    const request = urlParams.get('request');
+    if (request == null) {
+      return undefined;
+    }
+    return JSON.parse(atob(request));
+  }
+
   isSupportedReportedParentOrigin(parentOrigin: string): boolean {
     return OriginAllowList.isAllowedIframeParentOrigin(parentOrigin);
   }
@@ -813,6 +877,22 @@
     }
   }
 
+  updateShareLink() {
+    const originalRequest = this.getReportedRequest();
+
+    const newRequest: RequestData = {
+      artifacts: [],
+    };
+    if (originalRequest && originalRequest.artifacts) {
+      newRequest.artifacts = originalRequest.artifacts;
+    }
+
+    const params = new URLSearchParams();
+    params.set('request', btoa(JSON.stringify(newRequest)));
+    const baseUrl = this.getReportedParentOrigin() || getRootUrl();
+    this.generatedShareLink = `${baseUrl}?${params.toString()}`;
+  }
+
   allTracesAreDumps(): boolean {
     for (const trace of this.timelineData.getTraces()) {
       if (!trace.isDump()) {
diff --git a/tools/winscope/src/app/components/app_component_test.ts b/tools/winscope/src/app/components/app_component_test.ts
index 7354413..12954cac 100644
--- a/tools/winscope/src/app/components/app_component_test.ts
+++ b/tools/winscope/src/app/components/app_component_test.ts
@@ -47,6 +47,7 @@
   NoopAnimationsModule,
 } from '@angular/platform-browser/animations';
 import {assertDefined} from 'common/assert';
+import {RequestData} from 'cross_tool/g3_proxy';
 import {DOWNLOAD_FILENAME_REGEX} from 'common/io';
 import {
   FailedToInitializeTimelineData,
@@ -515,6 +516,135 @@
     });
   });
 
+  describe('share button', () => {
+    let isInsideWinscopeProxyFrameSpy: jasmine.Spy;
+    let getReportedParentOriginSpy: jasmine.Spy;
+    let getReportedRequestSpy: jasmine.Spy;
+
+    beforeEach(() => {
+      isInsideWinscopeProxyFrameSpy = spyOn(
+        component,
+        'isInsideWinscopeProxyFrame',
+      ).and.returnValue(false);
+      getReportedParentOriginSpy = spyOn(
+        component,
+        'getReportedParentOrigin',
+      ).and.returnValue(null);
+      getReportedRequestSpy = spyOn(
+        component,
+        'getReportedRequest',
+      ).and.returnValue(undefined);
+    });
+
+    it('is not shown if not in winscope proxy iframe', () => {
+      isInsideWinscopeProxyFrameSpy.and.returnValue(false);
+      dom.detectChanges();
+      expect(dom.find('.share-btn')).toBeUndefined();
+    });
+
+    it('is shown if in winscope proxy iframe', () => {
+      isInsideWinscopeProxyFrameSpy.and.returnValue(true);
+      dom.detectChanges();
+      expect(dom.find('.share-btn')).toBeTruthy();
+    });
+
+    it('generates correct share link', async () => {
+      const parentOrigin = 'https://winscope.corp.google.com';
+      const request: RequestData = {
+        artifacts: [{name: 'artifact', invocationId: '123'}],
+      };
+      isInsideWinscopeProxyFrameSpy.and.returnValue(true);
+      getReportedParentOriginSpy.and.returnValue(parentOrigin);
+      getReportedRequestSpy.and.returnValue(request);
+
+      component.updateShareLink();
+      dom.detectChanges();
+
+      const params = new URLSearchParams();
+      params.set(
+        'request',
+        btoa(JSON.stringify({artifacts: request.artifacts})),
+      );
+      const expectedLink = `${parentOrigin}?${params.toString()}`;
+      expect(component.generatedShareLink).toEqual(expectedLink);
+
+      dom.findAndClick('.share-btn');
+      await dom.whenStable();
+
+      const shareInputElement = document.querySelector(
+        '.share-link-field input',
+      ) as HTMLInputElement;
+      assertDefined(shareInputElement);
+      expect(shareInputElement?.value).toEqual(expectedLink);
+
+      const copyButton = dom.getInDocument('.share-link-container button');
+      copyButton.checkDisabled(false);
+    });
+
+    it('generates correct share link with no artifacts', async () => {
+      const parentOrigin = 'https://winscope.corp.google.com';
+      const request: RequestData = {
+        artifacts: [],
+      };
+      isInsideWinscopeProxyFrameSpy.and.returnValue(true);
+      getReportedParentOriginSpy.and.returnValue(parentOrigin);
+      getReportedRequestSpy.and.returnValue(request);
+
+      component.updateShareLink();
+      dom.detectChanges();
+
+      const params = new URLSearchParams();
+      params.set('request', btoa(JSON.stringify({artifacts: []})));
+      const expectedLink = `${parentOrigin}?${params.toString()}`;
+      expect(component.generatedShareLink).toEqual(expectedLink);
+
+      dom.findAndClick('.share-btn');
+      await dom.whenStable();
+
+      const shareInputElement = document.querySelector(
+        '.share-link-field input',
+      ) as HTMLInputElement;
+      assertDefined(shareInputElement);
+      expect(shareInputElement?.value).toEqual(expectedLink);
+    });
+
+    it('generates correct share link when original request is undefined', async () => {
+      const parentOrigin = 'https://winscope.corp.google.com';
+      isInsideWinscopeProxyFrameSpy.and.returnValue(true);
+      getReportedParentOriginSpy.and.returnValue(parentOrigin);
+      getReportedRequestSpy.and.returnValue(undefined);
+
+      component.updateShareLink();
+      dom.detectChanges();
+
+      const params = new URLSearchParams();
+      params.set('request', btoa(JSON.stringify({artifacts: []})));
+      const expectedLink = `${parentOrigin}?${params.toString()}`;
+      expect(component.generatedShareLink).toEqual(expectedLink);
+
+      dom.findAndClick('.share-btn');
+      await dom.whenStable();
+
+      const shareInputElement = document.querySelector(
+        '.share-link-field input',
+      ) as HTMLInputElement;
+      assertDefined(shareInputElement);
+      expect(shareInputElement?.value).toEqual(expectedLink);
+    });
+
+    it('disables copy button when no link is generated', async () => {
+      isInsideWinscopeProxyFrameSpy.and.returnValue(true);
+      component.generatedShareLink = '';
+      dom.detectChanges();
+
+      dom.findAndClick('.share-btn');
+      await dom.whenStable();
+
+      const copyButton = dom.getInDocument('.share-link-container button');
+      copyButton.checkDisabled(true);
+    });
+  });
+
   function goToTraceView() {
     component.dataLoaded = true;
     component.showDataLoadedElements = true;
diff --git a/tools/winscope/src/cross_tool/g3_proxy.ts b/tools/winscope/src/cross_tool/g3_proxy.ts
new file mode 100644
index 0000000..bad2854
--- /dev/null
+++ b/tools/winscope/src/cross_tool/g3_proxy.ts
@@ -0,0 +1,29 @@
+/*
+ * Copyright (C) 2025 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.
+ */
+
+/** An identifier for a test artifact. */
+export declare interface ArtifactIdentifier {
+  name: string;
+  invocationId: string;
+}
+
+/** The request data to be encoded in the URL as a base64 string. */
+export declare interface RequestData {
+  artifacts: ArtifactIdentifier[];
+  testMode?: boolean;
+  origin?: string; // For tracking the origin of the request.
+  useBetaWinscope?: boolean;
+}