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;
+}