1/*
2 * Copyright (C) 2023 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17import {assertDefined} from 'common/assert_utils';
18import {FileUtils} from 'common/file_utils';
19import {INVALID_TIME_NS, TimeRange, Timestamp} from 'common/time';
20import {TIME_UNIT_TO_NANO} from 'common/time_units';
21import {UserNotificationsListener} from 'messaging/user_notifications_listener';
22import {TraceHasOldData, TraceOverridden} from 'messaging/user_warnings';
23import {FileAndParser} from 'parsers/file_and_parser';
24import {FileAndParsers} from 'parsers/file_and_parsers';
25import {Parser} from 'trace/parser';
26import {TraceFile} from 'trace/trace_file';
27import {TRACE_INFO} from 'trace/trace_info';
28import {TraceType} from 'trace/trace_type';
29
30export class LoadedParsers {
31  static readonly MAX_ALLOWED_TIME_GAP_BETWEEN_TRACES_NS = BigInt(
32    5 * TIME_UNIT_TO_NANO.m,
33  ); // 5m
34  static readonly MAX_ALLOWED_TIME_GAP_BETWEEN_RTE_OFFSET = BigInt(
35    5 * TIME_UNIT_TO_NANO.s,
36  ); // 5s
37  static readonly REAL_TIME_TRACES_WITHOUT_RTE_OFFSET = [
38    TraceType.CUJS,
39    TraceType.EVENT_LOG,
40  ];
41
42  private legacyParsers = new Array<FileAndParser>();
43  private perfettoParsers = new Array<FileAndParser>();
44
45  addParsers(
46    legacyParsers: FileAndParser[],
47    perfettoParsers: FileAndParsers | undefined,
48    userNotificationsListener: UserNotificationsListener,
49  ) {
50    if (perfettoParsers) {
51      this.addPerfettoParsers(perfettoParsers, userNotificationsListener);
52    }
53    // Traces were simultaneously upgraded to contain real-to-boottime or real-to-monotonic offsets.
54    // If we have a mix of parsers with and without offsets, the ones without must be dangling
55    // trace files with old data, and should be filtered out.
56    legacyParsers = this.filterOutParsersWithoutOffsetsIfRequired(
57      legacyParsers,
58      perfettoParsers,
59      userNotificationsListener,
60    );
61    legacyParsers = this.filterOutLegacyParsersWithOldData(
62      legacyParsers,
63      userNotificationsListener,
64    );
65    legacyParsers = this.filterScreenshotParsersIfRequired(
66      legacyParsers,
67      userNotificationsListener,
68    );
69
70    this.addLegacyParsers(legacyParsers, userNotificationsListener);
71
72    this.enforceLimitOfSingleScreenshotOrScreenRecordingParser(
73      userNotificationsListener,
74    );
75  }
76
77  getParsers(): Array<Parser<object>> {
78    const fileAndParsers = [
79      ...this.legacyParsers.values(),
80      ...this.perfettoParsers.values(),
81    ];
82    return fileAndParsers.map((fileAndParser) => fileAndParser.parser);
83  }
84
85  remove(parser: Parser<object>) {
86    this.legacyParsers = this.legacyParsers.filter(
87      (fileAndParser) => fileAndParser.parser !== parser,
88    );
89    this.perfettoParsers = this.perfettoParsers.filter(
90      (fileAndParser) => fileAndParser.parser !== parser,
91    );
92  }
93
94  clear() {
95    this.legacyParsers = [];
96    this.perfettoParsers = [];
97  }
98
99  async makeZipArchive(): Promise<Blob> {
100    const outputFilesSoFar = new Set<File>();
101    const outputFilenameToFiles = new Map<string, File[]>();
102
103    const tryPushOutputFile = (file: File, filename: string) => {
104      // Remove duplicates because some parsers (e.g. view capture) could share the same file
105      if (outputFilesSoFar.has(file)) {
106        return;
107      }
108      outputFilesSoFar.add(file);
109
110      if (outputFilenameToFiles.get(filename) === undefined) {
111        outputFilenameToFiles.set(filename, []);
112      }
113      assertDefined(outputFilenameToFiles.get(filename)).push(file);
114    };
115
116    const makeArchiveFile = (
117      filename: string,
118      file: File,
119      clashCount: number,
120    ): File => {
121      if (clashCount === 0) {
122        return new File([file], filename);
123      }
124
125      const filenameWithoutExt =
126        FileUtils.removeExtensionFromFilename(filename);
127      const extension = FileUtils.getFileExtension(filename);
128
129      if (extension === undefined) {
130        return new File([file], `${filename} (${clashCount})`);
131      }
132
133      return new File(
134        [file],
135        `${filenameWithoutExt} (${clashCount}).${extension}`,
136      );
137    };
138
139    if (this.perfettoParsers.length > 0) {
140      const file: TraceFile = this.perfettoParsers.values().next().value.file;
141      let outputFilename = FileUtils.removeDirFromFileName(file.file.name);
142      if (FileUtils.getFileExtension(file.file.name) === undefined) {
143        outputFilename += '.perfetto-trace';
144      }
145      tryPushOutputFile(file.file, outputFilename);
146    }
147
148    this.legacyParsers.forEach(({file, parser}) => {
149      const traceType = parser.getTraceType();
150      const archiveDir =
151        TRACE_INFO[traceType].downloadArchiveDir.length > 0
152          ? TRACE_INFO[traceType].downloadArchiveDir + '/'
153          : '';
154      let outputFilename =
155        archiveDir + FileUtils.removeDirFromFileName(file.file.name);
156      if (FileUtils.getFileExtension(file.file.name) === undefined) {
157        outputFilename += TRACE_INFO[traceType].legacyExt;
158      }
159      tryPushOutputFile(file.file, outputFilename);
160    });
161
162    const archiveFiles = [...outputFilenameToFiles.entries()]
163      .map(([filename, files]) => {
164        return files.map((file, clashCount) =>
165          makeArchiveFile(filename, file, clashCount),
166        );
167      })
168      .flat();
169
170    return await FileUtils.createZipArchive(archiveFiles);
171  }
172
173  getLatestRealToMonotonicOffset(
174    parsers: Array<Parser<object>>,
175  ): bigint | undefined {
176    const p = parsers
177      .filter((offset) => offset.getRealToMonotonicTimeOffsetNs() !== undefined)
178      .sort((a, b) => {
179        return Number(
180          (a.getRealToMonotonicTimeOffsetNs() ?? 0n) -
181            (b.getRealToMonotonicTimeOffsetNs() ?? 0n),
182        );
183      })
184      .at(-1);
185    return p?.getRealToMonotonicTimeOffsetNs();
186  }
187
188  getLatestRealToBootTimeOffset(
189    parsers: Array<Parser<object>>,
190  ): bigint | undefined {
191    const p = parsers
192      .filter((offset) => offset.getRealToBootTimeOffsetNs() !== undefined)
193      .sort((a, b) => {
194        return Number(
195          (a.getRealToBootTimeOffsetNs() ?? 0n) -
196            (b.getRealToBootTimeOffsetNs() ?? 0n),
197        );
198      })
199      .at(-1);
200    return p?.getRealToBootTimeOffsetNs();
201  }
202
203  private addLegacyParsers(
204    parsers: FileAndParser[],
205    userNotificationsListener: UserNotificationsListener,
206  ) {
207    const legacyParsersBeingLoaded = new Map<TraceType, Parser<object>>();
208
209    parsers.forEach((fileAndParser) => {
210      const {parser} = fileAndParser;
211      if (
212        this.shouldUseLegacyParser(
213          parser,
214          legacyParsersBeingLoaded,
215          userNotificationsListener,
216        )
217      ) {
218        legacyParsersBeingLoaded.set(parser.getTraceType(), parser);
219        this.legacyParsers.push(fileAndParser);
220      }
221    });
222  }
223
224  private addPerfettoParsers(
225    {file, parsers}: FileAndParsers,
226    userNotificationsListener: UserNotificationsListener,
227  ) {
228    // We currently run only one Perfetto TP WebWorker at a time, so Perfetto parsers previously
229    // loaded are now invalid and must be removed (previous WebWorker is not running anymore).
230    this.perfettoParsers = [];
231
232    parsers.forEach((parser) => {
233      this.perfettoParsers.push(new FileAndParser(file, parser));
234
235      // While transitioning to the Perfetto format, devices might still have old legacy trace files
236      // dangling in the disk that get automatically included into bugreports. Hence, Perfetto
237      // parsers must always override legacy ones so that dangling legacy files are ignored.
238      this.legacyParsers = this.legacyParsers.filter((fileAndParser) => {
239        const isOverriddenByPerfettoParser =
240          fileAndParser.parser.getTraceType() === parser.getTraceType();
241        if (isOverriddenByPerfettoParser) {
242          userNotificationsListener.onNotifications([
243            new TraceOverridden(fileAndParser.parser.getDescriptors().join()),
244          ]);
245        }
246        return !isOverriddenByPerfettoParser;
247      });
248    });
249  }
250
251  private shouldUseLegacyParser(
252    newParser: Parser<object>,
253    parsersBeingLoaded: Map<TraceType, Parser<object>>,
254    userNotificationsListener: UserNotificationsListener,
255  ): boolean {
256    // While transitioning to the Perfetto format, devices might still have old legacy trace files
257    // dangling in the disk that get automatically included into bugreports. Hence, Perfetto parsers
258    // must always override legacy ones so that dangling legacy files are ignored.
259    const isOverriddenByPerfettoParser = this.perfettoParsers.some(
260      (fileAndParser) =>
261        fileAndParser.parser.getTraceType() === newParser.getTraceType(),
262    );
263    if (isOverriddenByPerfettoParser) {
264      userNotificationsListener.onNotifications([
265        new TraceOverridden(newParser.getDescriptors().join()),
266      ]);
267      return false;
268    }
269
270    return true;
271  }
272
273  private filterOutLegacyParsersWithOldData(
274    newLegacyParsers: FileAndParser[],
275    userNotificationsListener: UserNotificationsListener,
276  ): FileAndParser[] {
277    let allParsers = [
278      ...newLegacyParsers,
279      ...this.legacyParsers.values(),
280      ...this.perfettoParsers.values(),
281    ];
282
283    const latestMonotonicOffset = this.getLatestRealToMonotonicOffset(
284      allParsers.map(({parser, file}) => parser),
285    );
286    const latestBootTimeOffset = this.getLatestRealToBootTimeOffset(
287      allParsers.map(({parser, file}) => parser),
288    );
289
290    newLegacyParsers = newLegacyParsers.filter(({parser, file}) => {
291      const monotonicOffset = parser.getRealToMonotonicTimeOffsetNs();
292      if (monotonicOffset && latestMonotonicOffset) {
293        const isOldData =
294          Math.abs(Number(monotonicOffset - latestMonotonicOffset)) >
295          LoadedParsers.MAX_ALLOWED_TIME_GAP_BETWEEN_RTE_OFFSET;
296        if (isOldData) {
297          userNotificationsListener.onNotifications([
298            new TraceHasOldData(file.getDescriptor()),
299          ]);
300          return false;
301        }
302      }
303
304      const bootTimeOffset = parser.getRealToBootTimeOffsetNs();
305      if (bootTimeOffset && latestBootTimeOffset) {
306        const isOldData =
307          Math.abs(Number(bootTimeOffset - latestBootTimeOffset)) >
308          LoadedParsers.MAX_ALLOWED_TIME_GAP_BETWEEN_RTE_OFFSET;
309        if (isOldData) {
310          userNotificationsListener.onNotifications([
311            new TraceHasOldData(file.getDescriptor()),
312          ]);
313          return false;
314        }
315      }
316
317      return true;
318    });
319
320    allParsers = [
321      ...newLegacyParsers,
322      ...this.legacyParsers.values(),
323      ...this.perfettoParsers.values(),
324    ];
325
326    const timeRanges = allParsers
327      .map(({parser}) => {
328        const timestamps = parser.getTimestamps();
329        if (!timestamps || timestamps.length === 0) {
330          return undefined;
331        }
332        return new TimeRange(timestamps[0], timestamps[timestamps.length - 1]);
333      })
334      .filter((range) => range !== undefined) as TimeRange[];
335
336    const timeGap = this.findLastTimeGapAboveThreshold(timeRanges);
337    if (!timeGap) {
338      return newLegacyParsers;
339    }
340
341    return newLegacyParsers.filter(({parser, file}) => {
342      // Only Shell Transition data used to set timestamps of merged Transition trace,
343      // so WM Transition data should not be considered by "old data" policy
344      if (parser.getTraceType() === TraceType.WM_TRANSITION) {
345        return true;
346      }
347
348      let timestamps = parser.getTimestamps();
349      if (!this.hasValidTimestamps(timestamps)) {
350        return true;
351      }
352      timestamps = assertDefined(timestamps);
353
354      const endTimestamp = timestamps[timestamps.length - 1];
355      const isOldData = endTimestamp.getValueNs() <= timeGap.from.getValueNs();
356      if (isOldData) {
357        userNotificationsListener.onNotifications([
358          new TraceHasOldData(file.getDescriptor(), timeGap),
359        ]);
360        return false;
361      }
362
363      return true;
364    });
365  }
366
367  private filterScreenshotParsersIfRequired(
368    newLegacyParsers: FileAndParser[],
369    userNotificationsListener: UserNotificationsListener,
370  ): FileAndParser[] {
371    const hasOldScreenRecordingParsers = this.legacyParsers.some(
372      (entry) => entry.parser.getTraceType() === TraceType.SCREEN_RECORDING,
373    );
374    const hasNewScreenRecordingParsers = newLegacyParsers.some(
375      (entry) => entry.parser.getTraceType() === TraceType.SCREEN_RECORDING,
376    );
377    const hasScreenRecordingParsers =
378      hasOldScreenRecordingParsers || hasNewScreenRecordingParsers;
379
380    if (!hasScreenRecordingParsers) {
381      return newLegacyParsers;
382    }
383
384    const oldScreenshotParsers = this.legacyParsers.filter(
385      (fileAndParser) =>
386        fileAndParser.parser.getTraceType() === TraceType.SCREENSHOT,
387    );
388    const newScreenshotParsers = newLegacyParsers.filter(
389      (fileAndParser) =>
390        fileAndParser.parser.getTraceType() === TraceType.SCREENSHOT,
391    );
392
393    oldScreenshotParsers.forEach((fileAndParser) => {
394      userNotificationsListener.onNotifications([
395        new TraceOverridden(
396          fileAndParser.parser.getDescriptors().join(),
397          TraceType.SCREEN_RECORDING,
398        ),
399      ]);
400      this.remove(fileAndParser.parser);
401    });
402
403    newScreenshotParsers.forEach((newScreenshotParser) => {
404      userNotificationsListener.onNotifications([
405        new TraceOverridden(
406          newScreenshotParser.parser.getDescriptors().join(),
407          TraceType.SCREEN_RECORDING,
408        ),
409      ]);
410    });
411
412    return newLegacyParsers.filter(
413      (fileAndParser) =>
414        fileAndParser.parser.getTraceType() !== TraceType.SCREENSHOT,
415    );
416  }
417
418  private filterOutParsersWithoutOffsetsIfRequired(
419    newLegacyParsers: FileAndParser[],
420    perfettoParsers: FileAndParsers | undefined,
421    userNotificationsListener: UserNotificationsListener,
422  ): FileAndParser[] {
423    const hasParserWithOffset =
424      perfettoParsers ||
425      newLegacyParsers.find(({parser, file}) => {
426        return (
427          parser.getRealToBootTimeOffsetNs() !== undefined ||
428          parser.getRealToMonotonicTimeOffsetNs() !== undefined
429        );
430      });
431    const hasParserWithoutOffset = newLegacyParsers.find(({parser, file}) => {
432      const timestamps = parser.getTimestamps();
433      return (
434        this.hasValidTimestamps(timestamps) &&
435        parser.getRealToBootTimeOffsetNs() === undefined &&
436        parser.getRealToMonotonicTimeOffsetNs() === undefined
437      );
438    });
439
440    if (hasParserWithOffset && hasParserWithoutOffset) {
441      return newLegacyParsers.filter(({parser, file}) => {
442        if (
443          LoadedParsers.REAL_TIME_TRACES_WITHOUT_RTE_OFFSET.some(
444            (traceType) => parser.getTraceType() === traceType,
445          )
446        ) {
447          return true;
448        }
449        const hasOffset =
450          parser.getRealToMonotonicTimeOffsetNs() !== undefined ||
451          parser.getRealToBootTimeOffsetNs() !== undefined;
452        if (!hasOffset) {
453          userNotificationsListener.onNotifications([
454            new TraceHasOldData(parser.getDescriptors().join()),
455          ]);
456        }
457        return hasOffset;
458      });
459    }
460
461    return newLegacyParsers;
462  }
463
464  private enforceLimitOfSingleScreenshotOrScreenRecordingParser(
465    userNotificationsListener: UserNotificationsListener,
466  ) {
467    let firstScreenshotOrScreenrecordingParser: Parser<object> | undefined;
468
469    this.legacyParsers = this.legacyParsers.filter((fileAndParser) => {
470      const parser = fileAndParser.parser;
471      if (
472        parser.getTraceType() !== TraceType.SCREENSHOT &&
473        parser.getTraceType() !== TraceType.SCREEN_RECORDING
474      ) {
475        return true;
476      }
477
478      if (firstScreenshotOrScreenrecordingParser) {
479        userNotificationsListener.onNotifications([
480          new TraceOverridden(
481            parser.getDescriptors().join(),
482            firstScreenshotOrScreenrecordingParser.getTraceType(),
483          ),
484        ]);
485        return false;
486      }
487
488      firstScreenshotOrScreenrecordingParser = parser;
489      return true;
490    });
491  }
492
493  private findLastTimeGapAboveThreshold(
494    ranges: readonly TimeRange[],
495  ): TimeRange | undefined {
496    const rangesSortedByEnd = ranges
497      .slice()
498      .sort((a, b) => (a.to.getValueNs() < b.to.getValueNs() ? -1 : +1));
499
500    for (let i = rangesSortedByEnd.length - 2; i >= 0; --i) {
501      const curr = rangesSortedByEnd[i];
502      const next = rangesSortedByEnd[i + 1];
503      const gap = next.from.getValueNs() - curr.to.getValueNs();
504      if (gap > LoadedParsers.MAX_ALLOWED_TIME_GAP_BETWEEN_TRACES_NS) {
505        return new TimeRange(curr.to, next.from);
506      }
507    }
508
509    return undefined;
510  }
511
512  private hasValidTimestamps(timestamps: Timestamp[] | undefined): boolean {
513    if (!timestamps || timestamps.length === 0) {
514      return false;
515    }
516
517    const isDump =
518      timestamps.length === 1 && timestamps[0].getValueNs() === INVALID_TIME_NS;
519    if (isDump) {
520      return false;
521    }
522    return true;
523  }
524}
525