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>