1<!-- Copyright (C) 2019 The Android Open Source Project
2
3     Licensed under the Apache License, Version 2.0 (the "License");
4     you may not use this file except in compliance with the License.
5     You may obtain a copy of the License at
6
7          http://www.apache.org/licenses/LICENSE-2.0
8
9     Unless required by applicable law or agreed to in writing, software
10     distributed under the License is distributed on an "AS IS" BASIS,
11     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12     See the License for the specific language governing permissions and
13     limitations under the License.
14-->
15<template>
16<div @dragleave="fileDragOut" @dragover="fileDragIn" @drop="handleFileDrop">
17  <flat-card style="min-width: 50em">
18    <md-card-header>
19      <div class="md-title">Open files</div>
20    </md-card-header>
21    <md-card-content>
22      <div class="dropbox">
23        <md-list style="background: none">
24          <md-list-item v-for="file in dataFiles" v-bind:key="file.filename">
25            <md-icon>{{FILE_ICONS[file.type]}}</md-icon>
26            <span class="md-list-item-text">{{file.filename}} ({{file.type}})
27            </span>
28            <md-button
29              class="md-icon-button md-accent"
30              @click="onRemoveFile(file.type)"
31            >
32              <md-icon>close</md-icon>
33            </md-button>
34          </md-list-item>
35        </md-list>
36        <md-progress-spinner
37          :md-diameter="30"
38          :md-stroke="3"
39          md-mode="indeterminate"
40          v-show="loadingFiles"
41          class="progress-spinner"
42        />
43        <input
44          type="file"
45          @change="onLoadFile"
46          v-on:drop="handleFileDrop"
47          ref="fileUpload"
48          id="dropzone"
49          v-show="false"
50          multiple
51        />
52          <p v-if="!dataReady">
53            Drag your <b>.winscope</b> or <b>.zip</b> file(s) here to begin
54          </p>
55        </div>
56
57      <div class="md-layout">
58        <div class="md-layout-item md-small-size-100">
59          <md-field>
60          <md-select v-model="fileType" id="file-type" placeholder="File type">
61            <md-option value="auto">Detect type</md-option>
62            <md-option value="bugreport">Bug Report (.zip)</md-option>
63            <md-option
64              :value="k" v-for="(v,k) in FILE_DECODERS"
65              v-bind:key="v.name">{{v.name}}
66            ></md-option>
67          </md-select>
68          </md-field>
69        </div>
70      </div>
71      <div class="md-layout">
72        <md-button
73          class="md-primary md-theme-default"
74          @click="$refs.fileUpload.click()"
75        >
76          Add File
77        </md-button>
78        <md-button
79          v-if="dataReady"
80          @click="onSubmit"
81          class="md-button md-primary md-raised md-theme-default"
82        >
83          Submit
84        </md-button>
85      </div>
86    </md-card-content>
87
88    <md-snackbar
89      md-position="center"
90      :md-duration="Infinity"
91      :md-active.sync="showFetchingSnackbar"
92      md-persistent
93    >
94      <span>{{ fetchingSnackbarText }}</span>
95    </md-snackbar>
96
97    <md-snackbar
98      md-position="center"
99      :md-duration="snackbarDuration"
100      :md-active.sync="showSnackbar"
101      md-persistent
102    >
103      <p class="snackbar-break-words">{{ snackbarText }}</p>
104      <div @click="hideSnackbarMessage()">
105        <md-button class="md-icon-button">
106          <md-icon style="color: white">close</md-icon>
107        </md-button>
108      </div>
109    </md-snackbar>
110  </flat-card>
111</div>
112</template>
113<script>
114import FlatCard from './components/FlatCard.vue';
115import JSZip from 'jszip';
116import {
117  detectAndDecode,
118  FILE_TYPES,
119  FILE_DECODERS,
120  FILE_ICONS,
121  UndetectableFileType,
122} from './decode.js';
123import {WebContentScriptMessageType} from './utils/consts';
124
125export default {
126  name: 'datainput',
127  data() {
128    return {
129      FILE_TYPES,
130      FILE_DECODERS,
131      FILE_ICONS,
132      fileType: 'auto',
133      dataFiles: {},
134      loadingFiles: false,
135      showFetchingSnackbar: false,
136      showSnackbar: false,
137      snackbarDuration: 3500,
138      snackbarText: '',
139      fetchingSnackbarText: 'Fetching files...',
140    };
141  },
142  props: ['store'],
143  created() {
144    // Attempt to load files from extension if present
145    this.loadFilesFromExtension();
146  },
147  methods: {
148    showSnackbarMessage(message, duration) {
149      this.snackbarText = '\n' + message + '\n';
150      this.snackbarDuration = duration;
151      this.showSnackbar = true;
152    },
153    hideSnackbarMessage() {
154      this.showSnackbar = false;
155      this.buttonClicked("Hide Snackbar Message")
156    },
157    getFetchFilesLoadingAnimation() {
158      let frame = 0;
159      const fetchingStatusAnimation = () => {
160        frame++;
161        this.fetchingSnackbarText = `Fetching files${'.'.repeat(frame % 4)}`;
162      };
163      let interval = undefined;
164
165      return Object.freeze({
166        start: () => {
167          this.showFetchingSnackbar = true;
168          interval = setInterval(fetchingStatusAnimation, 500);
169        },
170        stop: () => {
171          this.showFetchingSnackbar = false;
172          clearInterval(interval);
173        },
174      });
175    },
176    /**
177     * Attempt to load files from the extension if present.
178     *
179     * If the source URL parameter is set to the extension it make a request
180     * to the extension to fetch the files from the extension.
181     */
182    loadFilesFromExtension() {
183      const urlParams = new URLSearchParams(window.location.search);
184      if (urlParams.get('source') === 'openFromExtension' && chrome) {
185        // Fetch files from extension
186        const androidBugToolExtensionId = 'mbbaofdfoekifkfpgehgffcpagbbjkmj';
187
188        const loading = this.getFetchFilesLoadingAnimation();
189        loading.start();
190
191        // Request to convert the blob object url "blob:chrome-extension://xxx"
192        // the chrome extension has to a web downloadable url "blob:http://xxx".
193        chrome.runtime.sendMessage(androidBugToolExtensionId, {
194          action: WebContentScriptMessageType.CONVERT_OBJECT_URL,
195        }, async (response) => {
196          switch (response.action) {
197            case WebContentScriptMessageType.CONVERT_OBJECT_URL_RESPONSE:
198              if (response.attachments?.length > 0) {
199                const filesBlobPromises = response.attachments
200                    .map(async (attachment) => {
201                      const fileQueryResponse =
202                        await fetch(attachment.objectUrl);
203                      const blob = await fileQueryResponse.blob();
204
205                      /**
206                       * Note: The blob's media type is not correct.
207                       * It is always set to "image/png".
208                       * Context: http://google3/javascript/closure/html/safeurl.js?g=0&l=256&rcl=273756987
209                       */
210
211                      // Clone blob to clear media type.
212                      const file = new Blob([blob]);
213                      file.name = attachment.name;
214
215                      return file;
216                    });
217
218                const files = await Promise.all(filesBlobPromises);
219
220                loading.stop();
221                this.processFiles(files);
222              } else {
223                const failureMessages = 'Got no attachements from extension...';
224                console.warn(failureMessages);
225                this.showSnackbarMessage(failureMessages, 3500);
226              }
227              break;
228
229            default:
230              loading.stop();
231              const failureMessages =
232                'Received unhandled response code from extension.';
233              console.warn(failureMessages);
234              this.showSnackbarMessage(failureMessages, 3500);
235          }
236        });
237      }
238    },
239    fileDragIn(e) {
240      e.preventDefault();
241    },
242    fileDragOut(e) {
243      e.preventDefault();
244    },
245    handleFileDrop(e) {
246      e.preventDefault();
247      let droppedFiles = e.dataTransfer.files;
248      if(!droppedFiles) return;
249      // Record analytics event
250      this.draggedAndDropped(droppedFiles);
251
252      this.processFiles(droppedFiles);
253    },
254    onLoadFile(e) {
255      const files = event.target.files || event.dataTransfer.files;
256      this.uploadedFileThroughFilesystem(files);
257      this.processFiles(files);
258    },
259    async processFiles(files) {
260      let error;
261      const decodedFiles = [];
262      for (const file of files) {
263        try {
264          this.loadingFiles = true;
265          this.showSnackbarMessage(`Loading ${file.name}`, Infinity);
266          const result = await this.addFile(file);
267          decodedFiles.push(...result);
268          this.hideSnackbarMessage();
269        } catch (e) {
270          this.showSnackbarMessage(
271              `Failed to load '${file.name}'...\n${e}`, 5000);
272          console.error(e);
273          error = e;
274          break;
275        } finally {
276          this.loadingFiles = false;
277        }
278      }
279
280      event.target.value = '';
281
282      if (error) {
283        return;
284      }
285
286      // TODO: Handle the fact that we can now have multiple files of type
287      // FILE_TYPES.TRANSACTION_EVENTS_TRACE
288
289      const decodedFileTypes = new Set(Object.keys(this.dataFiles));
290      // A file is overridden if a file of the same type is upload twice, as
291      // Winscope currently only support at most one file to each type
292      const overriddenFileTypes = new Set();
293      const overriddenFiles = {}; // filetype => array of files
294      for (const decodedFile of decodedFiles) {
295        const dataType = decodedFile.filetype;
296
297        if (decodedFileTypes.has(dataType)) {
298          overriddenFileTypes.add(dataType);
299          (overriddenFiles[dataType] = overriddenFiles[dataType] || [])
300              .push(this.dataFiles[dataType]);
301        }
302        decodedFileTypes.add(dataType);
303
304        this.$set(this.dataFiles,
305            dataType, decodedFile.data);
306      }
307
308      // TODO(b/169305853): Remove this once we have magic numbers or another
309      // way to detect the file type more reliably.
310      for (const dataType in overriddenFiles) {
311        if (overriddenFiles.hasOwnProperty(dataType)) {
312          const files = overriddenFiles[dataType];
313          files.push(this.dataFiles[dataType]);
314
315          const selectedFile =
316              this.getMostLikelyCandidateFile(dataType, files);
317          this.$set(this.dataFiles, dataType, selectedFile);
318
319          // Remove selected file from overriden list
320          const index = files.indexOf(selectedFile);
321          files.splice(index, 1);
322        }
323      }
324
325      if (overriddenFileTypes.size > 0) {
326        this.displayFilesOverridenWarning(overriddenFiles);
327      }
328    },
329
330    /**
331     * Gets the file that is most likely to be the actual file of that type out
332     * of all the candidateFiles. This is required because there are some file
333     * types that have no magic number and may lead to false positives when
334     * decoding in decode.js. (b/169305853)
335     * @param {string} dataType - The type of the candidate files.
336     * @param {files[]} candidateFiles - The list all the files detected to be
337     *                                   of type dataType, passed in the order
338     *                                   they are detected/uploaded in.
339     * @return {file} - the most likely candidate.
340     */
341    getMostLikelyCandidateFile(dataType, candidateFiles) {
342      const keyWordsByDataType = {
343        [FILE_TYPES.WINDOW_MANAGER_DUMP]: 'window',
344        [FILE_TYPES.SURFACE_FLINGER_DUMP]: 'surface',
345      };
346
347      if (
348        !candidateFiles ||
349        !candidateFiles.length ||
350        candidateFiles.length == 0
351      ) {
352        throw new Error('No candidate files provided');
353      }
354
355      if (!keyWordsByDataType.hasOwnProperty(dataType)) {
356        console.warn(`setMostLikelyCandidateFile doesn't know how to handle ` +
357            `candidates of dataType ${dataType} – setting last candidate as ` +
358            `target file.`);
359
360        // We want to return the last candidate file so that, we always override
361        // old uploaded files with once of the latest uploaded files.
362        return candidateFiles.slice(-1)[0];
363      }
364
365      for (const file of candidateFiles) {
366        if (file.filename
367            .toLowerCase().includes(keyWordsByDataType[dataType])) {
368          return file;
369        }
370      }
371
372      // We want to return the last candidate file so that, we always override
373      // old uploaded files with once of the latest uploaded files.
374      return candidateFiles.slice(-1)[0];
375    },
376
377    /**
378     * Display a snackbar warning that files have been overriden and any
379     * relavant additional information in the logs.
380     * @param {{string: file[]}} overriddenFiles - a mapping from data types to
381     * the files of the of that datatype tha have been overriden.
382     */
383    displayFilesOverridenWarning(overriddenFiles) {
384      const overriddenFileTypes = Object.keys(overriddenFiles);
385      const overriddenCount = Object.values(overriddenFiles)
386          .map((files) => files.length).reduce((length, next) => length + next);
387
388      if (overriddenFileTypes.length === 1 && overriddenCount === 1) {
389        const type = overriddenFileTypes.values().next().value;
390        const overriddenFile = overriddenFiles[type][0].filename;
391        const keptFile = this.dataFiles[type].filename;
392        const message =
393          `'${overriddenFile}' is conflicting with '${keptFile}'. ` +
394          `Only '${keptFile}' will be kept. If you wish to display ` +
395          `'${overriddenFile}', please upload it again with no other file ` +
396          `of the same type.`;
397
398        this.showSnackbarMessage(`WARNING: ${message}`, Infinity);
399        console.warn(message);
400      } else {
401        const message = `Mutiple conflicting files have been uploaded. ` +
402          `${overriddenCount} files have been discarded. Please check the ` +
403          `developer console for more information.`;
404        this.showSnackbarMessage(`WARNING: ${message}`, Infinity);
405
406        const messageBuilder = [];
407        for (const type of overriddenFileTypes.values()) {
408          const keptFile = this.dataFiles[type].filename;
409          const overriddenFilesCount = overriddenFiles[type].length;
410
411          messageBuilder.push(`${overriddenFilesCount} file` +
412              `${overriddenFilesCount > 1 ? 's' : ''} of type ${type} ` +
413              `${overriddenFilesCount > 1 ? 'have' : 'has'} been ` +
414              `overridden. Only '${keptFile}' has been kept.`);
415        }
416
417        messageBuilder.push('');
418        messageBuilder.push('Please reupload the specific files you want ' +
419          'to read (one of each type).');
420        messageBuilder.push('');
421
422        messageBuilder.push('===============DISCARDED FILES===============');
423
424        for (const type of overriddenFileTypes.values()) {
425          const discardedFiles = overriddenFiles[type];
426
427          messageBuilder.push(`The following files of type ${type} ` +
428            `have been discarded:`);
429          for (const discardedFile of discardedFiles) {
430            messageBuilder.push(`  - ${discardedFile.filename}`);
431          }
432          messageBuilder.push('');
433        }
434
435        console.warn(messageBuilder.join('\n'));
436      }
437    },
438
439    getFileExtensions(file) {
440      const split = file.name.split('.');
441      if (split.length > 1) {
442        return split.pop();
443      }
444
445      return undefined;
446    },
447    async addFile(file) {
448      const decodedFiles = [];
449      const type = this.fileType;
450
451      const extension = this.getFileExtensions(file);
452
453      // extension === 'zip' is required on top of file.type ===
454      // 'application/zip' because when loaded from the extension the type is
455      // incorrect. See comment in loadFilesFromExtension() for more
456      // information.
457      if (type === 'bugreport' ||
458          (type === 'auto' && (extension === 'zip' ||
459            file.type === 'application/zip'))) {
460        const results = await this.decodeArchive(file);
461        decodedFiles.push(...results);
462      } else {
463        const decodedFile = await this.decodeFile(file);
464        decodedFiles.push(decodedFile);
465      }
466
467      return decodedFiles;
468    },
469    readFile(file) {
470      return new Promise((resolve, _) => {
471        const reader = new FileReader();
472        reader.onload = async (e) => {
473          const buffer = new Uint8Array(e.target.result);
474          resolve(buffer);
475        };
476        reader.readAsArrayBuffer(file);
477      });
478    },
479    async decodeFile(file) {
480      const buffer = await this.readFile(file);
481
482      let filetype = this.filetype;
483      let data;
484      if (filetype) {
485        const fileDecoder = FILE_DECODERS[filetype];
486        data = fileDecoder.decoder(
487            buffer, fileDecoder.decoderParams, file.name, this.store);
488      } else {
489        // Defaulting to auto — will attempt to detect file type
490        [filetype, data] = detectAndDecode(buffer, file.name, this.store);
491      }
492
493      return {filetype, data};
494    },
495    async decodeArchive(archive) {
496      const buffer = await this.readFile(archive);
497
498      const zip = new JSZip();
499      const content = await zip.loadAsync(buffer);
500
501      const decodedFiles = [];
502
503      for (const filename in content.files) {
504        if (content.files.hasOwnProperty(filename)) {
505          const file = content.files[filename];
506          if (file.dir) {
507            // Ignore directories
508            continue;
509          }
510
511          const fileBlob = await file.async('blob');
512          // Get only filename and remove rest of path
513          fileBlob.name = filename.split('/').slice(-1).pop();
514
515          try {
516            const decodedFile = await this.decodeFile(fileBlob);
517
518            decodedFiles.push(decodedFile);
519          } catch (e) {
520            if (!(e instanceof UndetectableFileType)) {
521              throw e;
522            }
523          }
524        }
525      }
526
527      if (decodedFiles.length == 0) {
528        throw new Error('No matching files found in archive', archive);
529      }
530
531      return decodedFiles;
532    },
533    onRemoveFile(typeName) {
534      this.$delete(this.dataFiles, typeName);
535    },
536    onSubmit() {
537      this.$emit('dataReady',
538          Object.keys(this.dataFiles).map((key) => this.dataFiles[key]));
539    },
540  },
541  computed: {
542    dataReady: function() {
543      return Object.keys(this.dataFiles).length > 0;
544    },
545  },
546  components: {
547    'flat-card': FlatCard,
548  },
549};
550
551</script>
552<style>
553  .dropbox:hover {
554      background: rgb(224, 224, 224);
555    }
556
557  .dropbox p {
558    font-size: 1.2em;
559    text-align: center;
560    padding: 50px 10px;
561  }
562
563  .dropbox {
564    outline: 2px dashed #448aff; /* the dash box */
565    outline-offset: -10px;
566    background: white;
567    color: #448aff;
568    padding: 10px 10px 10px 10px;
569    min-height: 200px; /* minimum height */
570    position: relative;
571    cursor: pointer;
572  }
573
574  .progress-spinner {
575    display: block;
576  }
577</style>