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