| <!-- Copyright (C) 2019 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. |
| --> |
| <template> |
| <div @dragleave="fileDragOut" @dragover="fileDragIn" @drop="handleFileDrop"> |
| <flat-card style="min-width: 50em"> |
| <md-card-header> |
| <div class="md-title">Open files</div> |
| </md-card-header> |
| <md-card-content> |
| <div class="dropbox"> |
| <md-list style="background: none"> |
| <md-list-item v-for="file in dataFiles" v-bind:key="file.filename"> |
| <md-icon>{{FILE_ICONS[file.type]}}</md-icon> |
| <span class="md-list-item-text">{{file.filename}} ({{file.type}}) |
| </span> |
| <md-button |
| class="md-icon-button md-accent" |
| @click="onRemoveFile(file.type)" |
| > |
| <md-icon>close</md-icon> |
| </md-button> |
| </md-list-item> |
| </md-list> |
| <md-progress-spinner |
| :md-diameter="30" |
| :md-stroke="3" |
| md-mode="indeterminate" |
| v-show="loadingFiles" |
| class="progress-spinner" |
| /> |
| <input |
| type="file" |
| @change="onLoadFile" |
| v-on:drop="handleFileDrop" |
| ref="fileUpload" |
| id="dropzone" |
| v-show="false" |
| webkitdirectory |
| directory |
| multiple |
| /> |
| <p v-if="!dataReady"> |
| Drag your <b>.winscope</b> or <b>.zip</b> file(s) here to begin |
| </p> |
| </div> |
| |
| <div class="md-layout"> |
| <div class="md-layout-item md-small-size-100"> |
| <md-field> |
| <md-select v-model="fileType" id="file-type" placeholder="File type"> |
| <md-option value="auto">Detect type</md-option> |
| <md-option value="bugreport">Bug Report (.zip)</md-option> |
| <md-option |
| :value="k" v-for="(v,k) in FILE_DECODERS" |
| v-bind:key="v.name">{{v.name}} |
| ></md-option> |
| </md-select> |
| </md-field> |
| </div> |
| </div> |
| <div class="md-layout"> |
| <md-button |
| class="md-primary md-theme-default" |
| @click="$refs.fileUpload.click()" |
| > |
| Add File |
| </md-button> |
| <md-button |
| v-if="dataReady" |
| @click="onSubmit" |
| class="md-button md-primary md-raised md-theme-default" |
| > |
| Submit |
| </md-button> |
| </div> |
| </md-card-content> |
| |
| <md-snackbar |
| md-position="center" |
| :md-duration="Infinity" |
| :md-active.sync="showFetchingSnackbar" |
| md-persistent |
| > |
| <span>{{ fetchingSnackbarText }}</span> |
| </md-snackbar> |
| |
| <md-snackbar |
| md-position="center" |
| :md-duration="snackbarDuration" |
| :md-active.sync="showSnackbar" |
| md-persistent |
| > |
| <p class="snackbar-break-words">{{ snackbarText }}</p> |
| <div @click="hideSnackbarMessage()"> |
| <md-button class="md-icon-button"> |
| <md-icon style="color: white">close</md-icon> |
| </md-button> |
| </div> |
| </md-snackbar> |
| </flat-card> |
| </div> |
| </template> |
| <script> |
| import FlatCard from './components/FlatCard.vue'; |
| import JSZip from 'jszip'; |
| import { |
| detectAndDecode, |
| FILE_TYPES, |
| FILE_DECODERS, |
| FILE_ICONS, |
| UndetectableFileType, |
| } from './decode.js'; |
| import {WebContentScriptMessageType} from './utils/consts'; |
| |
| export default { |
| name: 'datainput', |
| data() { |
| return { |
| FILE_TYPES, |
| FILE_DECODERS, |
| FILE_ICONS, |
| fileType: 'auto', |
| dataFiles: {}, |
| loadingFiles: false, |
| showFetchingSnackbar: false, |
| showSnackbar: false, |
| snackbarDuration: 3500, |
| snackbarText: '', |
| fetchingSnackbarText: 'Fetching files...', |
| }; |
| }, |
| props: ['store'], |
| created() { |
| // Attempt to load files from extension if present |
| this.loadFilesFromExtension(); |
| }, |
| methods: { |
| showSnackbarMessage(message, duration) { |
| this.snackbarText = '\n' + message + '\n'; |
| this.snackbarDuration = duration; |
| this.showSnackbar = true; |
| }, |
| hideSnackbarMessage() { |
| this.showSnackbar = false; |
| }, |
| getFetchFilesLoadingAnimation() { |
| let frame = 0; |
| const fetchingStatusAnimation = () => { |
| frame++; |
| this.fetchingSnackbarText = `Fetching files${'.'.repeat(frame % 4)}`; |
| }; |
| let interval = undefined; |
| |
| return Object.freeze({ |
| start: () => { |
| this.showFetchingSnackbar = true; |
| interval = setInterval(fetchingStatusAnimation, 500); |
| }, |
| stop: () => { |
| this.showFetchingSnackbar = false; |
| clearInterval(interval); |
| }, |
| }); |
| }, |
| /** |
| * Attempt to load files from the extension if present. |
| * |
| * If the source URL parameter is set to the extension it make a request |
| * to the extension to fetch the files from the extension. |
| */ |
| loadFilesFromExtension() { |
| const urlParams = new URLSearchParams(window.location.search); |
| if (urlParams.get('source') === 'openFromExtension' && chrome) { |
| // Fetch files from extension |
| const androidBugToolExtensionId = 'mbbaofdfoekifkfpgehgffcpagbbjkmj'; |
| |
| const loading = this.getFetchFilesLoadingAnimation(); |
| loading.start(); |
| |
| // Request to convert the blob object url "blob:chrome-extension://xxx" |
| // the chrome extension has to a web downloadable url "blob:http://xxx". |
| chrome.runtime.sendMessage(androidBugToolExtensionId, { |
| action: WebContentScriptMessageType.CONVERT_OBJECT_URL, |
| }, async (response) => { |
| switch (response.action) { |
| case WebContentScriptMessageType.CONVERT_OBJECT_URL_RESPONSE: |
| if (response.attachments?.length > 0) { |
| const filesBlobPromises = response.attachments |
| .map(async (attachment) => { |
| const fileQueryResponse = |
| await fetch(attachment.objectUrl); |
| const blob = await fileQueryResponse.blob(); |
| |
| /** |
| * Note: The blob's media type is not correct. |
| * It is always set to "image/png". |
| * Context: http://google3/javascript/closure/html/safeurl.js?g=0&l=256&rcl=273756987 |
| */ |
| |
| // Clone blob to clear media type. |
| const file = new Blob([blob]); |
| file.name = attachment.name; |
| |
| return file; |
| }); |
| |
| const files = await Promise.all(filesBlobPromises); |
| |
| loading.stop(); |
| this.processFiles(files); |
| } else { |
| const failureMessages = 'Got no attachements from extension...'; |
| console.warn(failureMessages); |
| this.showSnackbarMessage(failureMessages, 3500); |
| } |
| break; |
| |
| default: |
| loading.stop(); |
| const failureMessages = |
| 'Received unhandled response code from extension.'; |
| console.warn(failureMessages); |
| this.showSnackbarMessage(failureMessages, 3500); |
| } |
| }); |
| } |
| }, |
| fileDragIn(e) { |
| e.preventDefault(); |
| }, |
| fileDragOut(e) { |
| e.preventDefault(); |
| }, |
| handleFileDrop(e) { |
| e.preventDefault(); |
| let droppedFiles = e.dataTransfer.files; |
| if(!droppedFiles) return; |
| this.processFiles(droppedFiles); |
| }, |
| onLoadFile(e) { |
| const files = event.target.files || event.dataTransfer.files; |
| this.processFiles(files); |
| }, |
| async processFiles(files) { |
| let error; |
| const decodedFiles = []; |
| for (const file of files) { |
| try { |
| this.loadingFiles = true; |
| this.showSnackbarMessage(`Loading ${file.name}`, Infinity); |
| const result = await this.addFile(file); |
| decodedFiles.push(...result); |
| this.hideSnackbarMessage(); |
| } catch (e) { |
| this.showSnackbarMessage( |
| `Failed to load '${file.name}'...\n${e}`, 5000); |
| console.error(e); |
| error = e; |
| break; |
| } finally { |
| this.loadingFiles = false; |
| } |
| } |
| |
| event.target.value = ''; |
| |
| if (error) { |
| return; |
| } |
| |
| // TODO: Handle the fact that we can now have multiple files of type |
| // FILE_TYPES.TRANSACTION_EVENTS_TRACE |
| |
| const decodedFileTypes = new Set(Object.keys(this.dataFiles)); |
| // A file is overridden if a file of the same type is upload twice, as |
| // Winscope currently only support at most one file to each type |
| const overriddenFileTypes = new Set(); |
| const overriddenFiles = {}; // filetype => array of files |
| for (const decodedFile of decodedFiles) { |
| const dataType = decodedFile.filetype; |
| |
| if (decodedFileTypes.has(dataType)) { |
| overriddenFileTypes.add(dataType); |
| (overriddenFiles[dataType] = overriddenFiles[dataType] || []) |
| .push(this.dataFiles[dataType]); |
| } |
| decodedFileTypes.add(dataType); |
| |
| this.$set(this.dataFiles, |
| dataType, decodedFile.data); |
| } |
| |
| // TODO(b/169305853): Remove this once we have magic numbers or another |
| // way to detect the file type more reliably. |
| for (const dataType in overriddenFiles) { |
| if (overriddenFiles.hasOwnProperty(dataType)) { |
| const files = overriddenFiles[dataType]; |
| files.push(this.dataFiles[dataType]); |
| |
| const selectedFile = |
| this.getMostLikelyCandidateFile(dataType, files); |
| this.$set(this.dataFiles, dataType, selectedFile); |
| |
| // Remove selected file from overriden list |
| const index = files.indexOf(selectedFile); |
| files.splice(index, 1); |
| } |
| } |
| |
| if (overriddenFileTypes.size > 0) { |
| this.displayFilesOverridenWarning(overriddenFiles); |
| } |
| }, |
| |
| /** |
| * Gets the file that is most likely to be the actual file of that type out |
| * of all the candidateFiles. This is required because there are some file |
| * types that have no magic number and may lead to false positives when |
| * decoding in decode.js. (b/169305853) |
| * @param {string} dataType - The type of the candidate files. |
| * @param {files[]} candidateFiles - The list all the files detected to be |
| * of type dataType, passed in the order |
| * they are detected/uploaded in. |
| * @return {file} - the most likely candidate. |
| */ |
| getMostLikelyCandidateFile(dataType, candidateFiles) { |
| const keyWordsByDataType = { |
| [FILE_TYPES.WINDOW_MANAGER_DUMP]: 'window', |
| [FILE_TYPES.SURFACE_FLINGER_DUMP]: 'surface', |
| }; |
| |
| if ( |
| !candidateFiles || |
| !candidateFiles.length || |
| candidateFiles.length == 0 |
| ) { |
| throw new Error('No candidate files provided'); |
| } |
| |
| if (!keyWordsByDataType.hasOwnProperty(dataType)) { |
| console.warn(`setMostLikelyCandidateFile doesn't know how to handle ` + |
| `candidates of dataType ${dataType} – setting last candidate as ` + |
| `target file.`); |
| |
| // We want to return the last candidate file so that, we always override |
| // old uploaded files with once of the latest uploaded files. |
| return candidateFiles.slice(-1)[0]; |
| } |
| |
| for (const file of candidateFiles) { |
| if (file.filename |
| .toLowerCase().includes(keyWordsByDataType[dataType])) { |
| return file; |
| } |
| } |
| |
| // We want to return the last candidate file so that, we always override |
| // old uploaded files with once of the latest uploaded files. |
| return candidateFiles.slice(-1)[0]; |
| }, |
| |
| /** |
| * Display a snackbar warning that files have been overriden and any |
| * relavant additional information in the logs. |
| * @param {{string: file[]}} overriddenFiles - a mapping from data types to |
| * the files of the of that datatype tha have been overriden. |
| */ |
| displayFilesOverridenWarning(overriddenFiles) { |
| const overriddenFileTypes = Object.keys(overriddenFiles); |
| const overriddenCount = Object.values(overriddenFiles) |
| .map((files) => files.length).reduce((length, next) => length + next); |
| |
| if (overriddenFileTypes.length === 1 && overriddenCount === 1) { |
| const type = overriddenFileTypes.values().next().value; |
| const overriddenFile = overriddenFiles[type][0].filename; |
| const keptFile = this.dataFiles[type].filename; |
| const message = |
| `'${overriddenFile}' is conflicting with '${keptFile}'. ` + |
| `Only '${keptFile}' will be kept. If you wish to display ` + |
| `'${overriddenFile}', please upload it again with no other file ` + |
| `of the same type.`; |
| |
| this.showSnackbarMessage(`WARNING: ${message}`, Infinity); |
| console.warn(message); |
| } else { |
| const message = `Mutiple conflicting files have been uploaded. ` + |
| `${overriddenCount} files have been discarded. Please check the ` + |
| `developer console for more information.`; |
| this.showSnackbarMessage(`WARNING: ${message}`, Infinity); |
| |
| const messageBuilder = []; |
| for (const type of overriddenFileTypes.values()) { |
| const keptFile = this.dataFiles[type].filename; |
| const overriddenFilesCount = overriddenFiles[type].length; |
| |
| messageBuilder.push(`${overriddenFilesCount} file` + |
| `${overriddenFilesCount > 1 ? 's' : ''} of type ${type} ` + |
| `${overriddenFilesCount > 1 ? 'have' : 'has'} been ` + |
| `overridden. Only '${keptFile}' has been kept.`); |
| } |
| |
| messageBuilder.push(''); |
| messageBuilder.push('Please reupload the specific files you want ' + |
| 'to read (one of each type).'); |
| messageBuilder.push(''); |
| |
| messageBuilder.push('===============DISCARDED FILES==============='); |
| |
| for (const type of overriddenFileTypes.values()) { |
| const discardedFiles = overriddenFiles[type]; |
| |
| messageBuilder.push(`The following files of type ${type} ` + |
| `have been discarded:`); |
| for (const discardedFile of discardedFiles) { |
| messageBuilder.push(` - ${discardedFile.filename}`); |
| } |
| messageBuilder.push(''); |
| } |
| |
| console.warn(messageBuilder.join('\n')); |
| } |
| }, |
| |
| getFileExtensions(file) { |
| const split = file.name.split('.'); |
| if (split.length > 1) { |
| return split.pop(); |
| } |
| |
| return undefined; |
| }, |
| async addFile(file) { |
| const decodedFiles = []; |
| const type = this.fileType; |
| |
| const extension = this.getFileExtensions(file); |
| |
| // extension === 'zip' is required on top of file.type === |
| // 'application/zip' because when loaded from the extension the type is |
| // incorrect. See comment in loadFilesFromExtension() for more |
| // information. |
| if (type === 'bugreport' || |
| (type === 'auto' && (extension === 'zip' || |
| file.type === 'application/zip'))) { |
| const results = await this.decodeArchive(file); |
| decodedFiles.push(...results); |
| } else { |
| const decodedFile = await this.decodeFile(file); |
| decodedFiles.push(decodedFile); |
| } |
| |
| return decodedFiles; |
| }, |
| readFile(file) { |
| return new Promise((resolve, _) => { |
| const reader = new FileReader(); |
| reader.onload = async (e) => { |
| const buffer = new Uint8Array(e.target.result); |
| resolve(buffer); |
| }; |
| reader.readAsArrayBuffer(file); |
| }); |
| }, |
| async decodeFile(file) { |
| const buffer = await this.readFile(file); |
| |
| let filetype = this.filetype; |
| let data; |
| if (filetype) { |
| const fileDecoder = FILE_DECODERS[filetype]; |
| data = fileDecoder.decoder( |
| buffer, fileDecoder.decoderParams, file.name, this.store); |
| } else { |
| // Defaulting to auto — will attempt to detect file type |
| [filetype, data] = detectAndDecode(buffer, file.name, this.store); |
| } |
| |
| return {filetype, data}; |
| }, |
| async decodeArchive(archive) { |
| const buffer = await this.readFile(archive); |
| |
| const zip = new JSZip(); |
| const content = await zip.loadAsync(buffer); |
| |
| const decodedFiles = []; |
| |
| for (const filename in content.files) { |
| if (content.files.hasOwnProperty(filename)) { |
| const file = content.files[filename]; |
| if (file.dir) { |
| // Ignore directories |
| continue; |
| } |
| |
| const fileBlob = await file.async('blob'); |
| // Get only filename and remove rest of path |
| fileBlob.name = filename.split('/').slice(-1).pop(); |
| |
| try { |
| const decodedFile = await this.decodeFile(fileBlob); |
| |
| decodedFiles.push(decodedFile); |
| } catch (e) { |
| if (!(e instanceof UndetectableFileType)) { |
| throw e; |
| } |
| } |
| } |
| } |
| |
| if (decodedFiles.length == 0) { |
| throw new Error('No matching files found in archive', archive); |
| } |
| |
| return decodedFiles; |
| }, |
| onRemoveFile(typeName) { |
| this.$delete(this.dataFiles, typeName); |
| }, |
| onSubmit() { |
| this.$emit('dataReady', |
| Object.keys(this.dataFiles).map((key) => this.dataFiles[key])); |
| }, |
| }, |
| computed: { |
| dataReady: function() { |
| return Object.keys(this.dataFiles).length > 0; |
| }, |
| }, |
| components: { |
| 'flat-card': FlatCard, |
| }, |
| }; |
| |
| </script> |
| <style> |
| .dropbox:hover { |
| background: rgb(224, 224, 224); |
| } |
| |
| .dropbox p { |
| font-size: 1.2em; |
| text-align: center; |
| padding: 50px 10px; |
| } |
| |
| .dropbox { |
| outline: 2px dashed #448aff; /* the dash box */ |
| outline-offset: -10px; |
| background: white; |
| color: #448aff; |
| padding: 10px 10px 10px 10px; |
| min-height: 200px; /* minimum height */ |
| position: relative; |
| cursor: pointer; |
| } |
| |
| .progress-spinner { |
| display: block; |
| } |
| </style> |