blob: d059b6acf291b216445e4437f4ab7a9a367a2c31 [file] [log] [blame]
<!-- 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" @click="$refs.fileUpload.click()" ref="dropbox">
<md-list
class="uploaded-files"
v-show="Object.keys(dataFiles).length > 0"
>
<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="e => {
e.stopPropagation()
onRemoveFile(file.type)
}"
>
<md-icon>close</md-icon>
</md-button>
</md-list-item>
</md-list>
<div class="progress-spinner-wrapper" v-show="loadingFiles">
<md-progress-spinner
:md-diameter="30"
:md-stroke="3"
md-mode="indeterminate"
class="progress-spinner"
/>
</div>
<input
type="file"
@change="onLoadFile"
v-on:drop="handleFileDrop"
ref="fileUpload"
id="dropzone"
v-show="false"
multiple
/>
<p v-if="!dataReady && !loadingFiles">
Drag your <b>.winscope</b> or <b>.zip</b> file(s) or click 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';
import {combineWmSfWithImeDataIfExisting} from './ime_processing.js';
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...',
traceName: undefined,
};
},
props: ['store'],
created() {
// Attempt to load files from extension if present
this.loadFilesFromExtension();
},
mounted() {
this.handleDropboxDragEvents();
},
beforeUnmount() {
},
methods: {
showSnackbarMessage(message, duration) {
this.snackbarText = '\n' + message + '\n';
this.snackbarDuration = duration;
this.showSnackbar = true;
},
hideSnackbarMessage() {
this.showSnackbar = false;
this.recordButtonClickedEvent("Hide Snackbar Message")
},
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);
},
});
},
handleDropboxDragEvents() {
// Counter used to keep track of when we actually exit the dropbox area
// When we drag over a child of the dropbox area the dragenter event will
// be called again and subsequently the dragleave so we don't want to just
// remove the class on the dragleave event.
let dropboxDragCounter = 0;
console.log(this.$refs["dropbox"])
this.$refs["dropbox"].addEventListener('dragenter', e => {
dropboxDragCounter++;
this.$refs["dropbox"].classList.add('dragover');
});
this.$refs["dropbox"].addEventListener('dragleave', e => {
dropboxDragCounter--;
if (dropboxDragCounter == 0) {
this.$refs["dropbox"].classList.remove('dragover');
}
});
this.$refs["dropbox"].addEventListener('drop', e => {
dropboxDragCounter = 0;
this.$refs["dropbox"].classList.remove('dragover');
});
},
/**
* 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;
// Record analytics event
this.recordDragAndDropFileEvent(droppedFiles);
this.processFiles(droppedFiles);
},
onLoadFile(e) {
const files = event.target.files || event.dataTransfer.files;
this.recordFileUploadEvent(files);
this.processFiles(files);
},
async processFiles(files) {
console.log("Object.keys(this.dataFiles).length", Object.keys(this.dataFiles).length)
// The trace name to use if we manage to load the archive without errors.
let tmpTraceName;
if (Object.keys(this.dataFiles).length > 0) {
// We have already loaded some files so only want to use the name of
// this archive as the name of the trace if we override all loaded files
} else {
// No files have been uploaded yet so if we are uploading only 1 archive
// we want to use it's name as the trace name
if (files.length == 1 && this.isArchive(files[0])) {
tmpTraceName = this.getFileNameWithoutZipExtension(files[0])
}
}
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);
const frozenData = Object.freeze(decodedFile.data.data);
delete decodedFile.data.data;
decodedFile.data.data = frozenData;
this.$set(this.dataFiles,
dataType, Object.freeze(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, Object.freeze(selectedFile));
// Remove selected file from overriden list
const index = files.indexOf(selectedFile);
files.splice(index, 1);
}
}
if (overriddenFileTypes.size > 0) {
this.displayFilesOverridenWarning(overriddenFiles);
}
if (tmpTraceName !== undefined) {
this.traceName = tmpTraceName;
}
if (this.store.betaFeatures.newImePanels) {
combineWmSfWithImeDataIfExisting(this.dataFiles);
}
},
getFileNameWithoutZipExtension(file) {
const fileNameSplitOnDot = file.name.split('.')
if (fileNameSplitOnDot.slice(-1)[0] == 'zip') {
return fileNameSplitOnDot.slice(0,-1).join('.');
} else {
return file.name;
}
},
/**
* 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;
},
isArchive(file) {
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.
return type === 'bugreport' ||
(type === 'auto' && (extension === 'zip' ||
file.type === 'application/zip'))
},
async addFile(file) {
const decodedFiles = [];
if (this.isArchive(file)) {
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};
},
/**
* Decode a zip file
*
* Load all files that can be decoded, even if some failures occur.
* For example, a zip file with an mp4 recorded via MediaProjection
* doesn't include the winscope metadata (b/140855415), but the trace
* files within the zip should be nevertheless readable
*/
async decodeArchive(archive) {
const buffer = await this.readFile(archive);
const zip = new JSZip();
const content = await zip.loadAsync(buffer);
const decodedFiles = [];
let lastError;
for (const filename in content.files) {
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)) {
lastError = e;
}
console.error(e);
}
}
if (decodedFiles.length == 0) {
if (lastError) {
throw lastError;
}
throw new Error('No matching files found in archive', archive);
} else {
if (lastError) {
this.showSnackbarMessage(
'Unable to parse all files, check log for more details', 3500);
}
}
return decodedFiles;
},
onRemoveFile(typeName) {
this.$delete(this.dataFiles, typeName);
},
onSubmit() {
this.$emit('dataReady', this.formattedTraceName,
Object.keys(this.dataFiles).map((key) => this.dataFiles[key]));
},
},
computed: {
dataReady: function() {
return Object.keys(this.dataFiles).length > 0;
},
formattedTraceName() {
if (this.traceName === undefined) {
return 'winscope-trace';
} else {
return this.traceName;
}
}
},
components: {
'flat-card': FlatCard,
},
};
</script>
<style>
.dropbox:hover, .dropbox.dragover {
background: rgb(224, 224, 224);
}
.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;
display: flex;
flex-direction: column;
align-items: center;
justify-items: center;
}
.dropbox p, .dropbox .progress-spinner-wrapper {
font-size: 1.2em;
margin: auto;
}
.progress-spinner-wrapper, .progress-spinner {
width: fit-content;
height: fit-content;
display: block;
}
.progress-spinner-wrapper {
padding: 1.5rem 0 1.5rem 0;
}
.dropbox .uploaded-files {
background: none!important;
width: 100%;
}
</style>