Initial commit 🎉

fbshipit-source-id: b6fc29740c6875d2e78953b8a7123890a67930f2
Co-authored-by: Sebastian McKenzie <sebmck@fb.com>
Co-authored-by: John Knox <jknox@fb.com>
Co-authored-by: Emil Sjölander <emilsj@fb.com>
Co-authored-by: Pritesh Nandgaonkar <prit91@fb.com>
This commit is contained in:
Daniel Büchele
2018-04-13 08:38:06 -07:00
committed by Daniel Buchele
commit fbbf8cf16b
659 changed files with 87130 additions and 0 deletions

View File

@@ -0,0 +1,323 @@
/**
* Copyright 2018-present Facebook.
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
* @format
*/
import {SonarDevicePlugin} from 'sonar';
var adb = require('adbkit-fb');
import {
FlexColumn,
FlexRow,
Button,
Toolbar,
Text,
ManagedTable,
colors,
} from 'sonar';
type ADBClient = any;
type AndroidDevice = any;
type TableRows = any;
// we keep vairable name with underline for to physical path mappings on device
type CPUFrequency = {|
cpu_id: number,
scaling_cur_freq: number,
scaling_min_freq: number,
scaling_max_freq: number,
cpuinfo_max_freq: number,
cpuinfo_min_freq: number,
|};
type CPUState = {|
cpuFreq: Array<CPUFrequency>,
cpuCount: number,
monitoring: boolean,
|};
type ShellCallBack = (output: string) => void;
const ColumnSizes = {
cpu_id: '10%',
scaling_cur_freq: 'flex',
scaling_min_freq: 'flex',
scaling_max_freq: 'flex',
cpuinfo_min_freq: 'flex',
cpuinfo_max_freq: 'flex',
};
const Columns = {
cpu_id: {
value: 'CPU ID',
resizable: true,
},
scaling_cur_freq: {
value: 'Scaling Current',
resizable: true,
},
scaling_min_freq: {
value: 'Scaling MIN',
resizable: true,
},
scaling_max_freq: {
value: 'Scaling MAX',
resizable: true,
},
cpuinfo_min_freq: {
value: 'MIN Frequency',
resizable: true,
},
cpuinfo_max_freq: {
value: 'MAX Frequency',
resizable: true,
},
};
// check if str is a number
function isNormalInteger(str) {
let n = Math.floor(Number(str));
return String(n) === str && n >= 0;
}
// format frequency to MHz, GHz
function formatFrequency(freq) {
if (freq == -1) {
return 'N/A';
} else if (freq == -2) {
return 'off';
} else if (freq > 1000 * 1000) {
return (freq / 1000 / 1000).toFixed(2) + ' GHz';
} else {
return freq / 1000 + ' MHz';
}
}
export default class CPUFrequencyTable extends SonarDevicePlugin<CPUState> {
static id = 'DeviceCPU';
static title = 'CPU';
static icon = 'underline';
adbClient: ADBClient;
intervalID: ?IntervalID;
device: AndroidDevice;
init() {
this.setState({
cpuFreq: [],
cpuCount: 0,
monitoring: false,
});
this.adbClient = this.device.adb;
// check how many cores we have on this device
this.executeShell((output: string) => {
let idx = output.indexOf('-');
let cpuFreq = [];
let count = parseInt(output.substring(idx + 1), 10) + 1;
for (let i = 0; i < count; ++i) {
cpuFreq[i] = {
cpu_id: i,
scaling_cur_freq: -1,
scaling_min_freq: -1,
scaling_max_freq: -1,
cpuinfo_min_freq: -1,
cpuinfo_max_freq: -1,
};
}
this.setState({
cpuCount: count,
cpuFreq: cpuFreq,
});
}, 'cat /sys/devices/system/cpu/possible');
}
executeShell = (callback: ShellCallBack, command: string) => {
this.adbClient
.shell(this.device.serial, command)
.then(adb.util.readAll)
.then(function(output) {
return callback(output.toString().trim());
});
};
updateCoreFrequency = (core: number, type: string) => {
this.executeShell((output: string) => {
let cpuFreq = this.state.cpuFreq;
let newFreq = isNormalInteger(output) ? parseInt(output, 10) : -1;
// update table only if frequency changed
if (cpuFreq[core][type] != newFreq) {
cpuFreq[core][type] = newFreq;
if (type == 'scaling_cur_freq' && cpuFreq[core][type] < 0) {
// cannot find current freq means offline
cpuFreq[core][type] = -2;
}
this.setState({
cpuFreq: cpuFreq,
});
}
}, 'cat /sys/devices/system/cpu/cpu' + core + '/cpufreq/' + type);
};
readCoreFrequency = (core: number) => {
let freq = this.state.cpuFreq[core];
if (freq.cpuinfo_max_freq < 0) {
this.updateCoreFrequency(core, 'cpuinfo_max_freq');
}
if (freq.cpuinfo_min_freq < 0) {
this.updateCoreFrequency(core, 'cpuinfo_min_freq');
}
this.updateCoreFrequency(core, 'scaling_cur_freq');
this.updateCoreFrequency(core, 'scaling_min_freq');
this.updateCoreFrequency(core, 'scaling_max_freq');
};
onStartMonitor = () => {
if (this.intervalID) {
return;
}
this.intervalID = setInterval(() => {
for (let i = 0; i < this.state.cpuCount; ++i) {
this.readCoreFrequency(i);
}
}, 500);
this.setState({
monitoring: true,
});
};
onStopMonitor = () => {
if (!this.intervalID) {
return;
} else {
clearInterval(this.intervalID);
this.intervalID = null;
this.setState({
monitoring: false,
});
this.cleanup();
}
};
cleanup = () => {
let cpuFreq = this.state.cpuFreq;
for (let i = 0; i < this.state.cpuCount; ++i) {
cpuFreq[i].scaling_cur_freq = -1;
cpuFreq[i].scaling_min_freq = -1;
cpuFreq[i].scaling_max_freq = -1;
// we don't cleanup cpuinfo_min_freq, cpuinfo_max_freq
// because usually they are fixed (hardware)
}
this.setState({
cpuFreq: cpuFreq,
});
};
teardown = () => {
this.cleanup();
};
buildRow = (freq: CPUFrequency) => {
let style = {};
if (freq.scaling_cur_freq == -2) {
style = {
style: {
backgroundColor: colors.blueTint30,
color: colors.white,
fontWeight: 700,
},
};
} else if (
freq.scaling_min_freq != freq.cpuinfo_min_freq &&
freq.scaling_min_freq > 0 &&
freq.cpuinfo_min_freq > 0
) {
style = {
style: {
backgroundColor: colors.redTint,
color: colors.red,
fontWeight: 700,
},
};
} else if (
freq.scaling_max_freq != freq.cpuinfo_max_freq &&
freq.scaling_max_freq > 0 &&
freq.cpuinfo_max_freq > 0
) {
style = {
style: {
backgroundColor: colors.yellowTint,
color: colors.yellow,
fontWeight: 700,
},
};
}
return {
columns: {
cpu_id: {value: <Text>CPU_{freq.cpu_id}</Text>},
scaling_cur_freq: {
value: <Text>{formatFrequency(freq.scaling_cur_freq)}</Text>,
},
scaling_min_freq: {
value: <Text>{formatFrequency(freq.scaling_min_freq)}</Text>,
},
scaling_max_freq: {
value: <Text>{formatFrequency(freq.scaling_max_freq)}</Text>,
},
cpuinfo_min_freq: {
value: <Text>{formatFrequency(freq.cpuinfo_min_freq)}</Text>,
},
cpuinfo_max_freq: {
value: <Text>{formatFrequency(freq.cpuinfo_max_freq)}</Text>,
},
},
key: freq.cpu_id,
style,
};
};
frequencyRows = (cpuFreqs: Array<CPUFrequency>): TableRows => {
let rows = [];
for (const cpuFreq of cpuFreqs) {
rows.push(this.buildRow(cpuFreq));
}
return rows;
};
render() {
return (
<FlexRow>
<FlexColumn fill={true}>
<Toolbar position="top">
{this.state.monitoring ? (
<Button onClick={this.onStopMonitor} icon="pause">
Pause
</Button>
) : (
<Button onClick={this.onStartMonitor} icon="play">
Start
</Button>
)}
</Toolbar>
<ManagedTable
multiline={true}
columnSizes={ColumnSizes}
columns={Columns}
autoHeight={true}
floating={false}
zebra={true}
rows={this.frequencyRows(this.state.cpuFreq)}
/>
</FlexColumn>
</FlexRow>
);
}
}

View File

@@ -0,0 +1,22 @@
/**
* Copyright 2018-present Facebook.
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
* @format
*/
import {GK} from 'sonar';
import logs from './logs/index.js';
import cpu from './cpu/index.js';
import screen from './screen/index.js';
const plugins = [logs];
if (GK.get('sonar_uiperf')) {
plugins.push(cpu);
}
if (GK.get('sonar_screen_plugin')) {
plugins.push(screen);
}
export const devicePlugins = plugins;

View File

@@ -0,0 +1,562 @@
/**
* Copyright 2018-present Facebook.
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
* @format
*/
import type {
TableBodyRow,
TableColumnOrder,
TableColumnSizes,
TableColumns,
} from 'sonar';
import type {Counter} from './LogWatcher.js';
import type {DeviceLogEntry} from '../../devices/BaseDevice.js';
import {
Text,
ManagedTable,
Button,
colors,
FlexCenter,
LoadingIndicator,
ContextMenu,
FlexColumn,
Glyph,
} from 'sonar';
import {SonarDevicePlugin, SearchableTable} from 'sonar';
import textContent from '../../utils/textContent.js';
import createPaste from '../../utils/createPaste.js';
import LogWatcher from './LogWatcher';
const LOG_WATCHER_LOCAL_STORAGE_KEY = 'LOG_WATCHER_LOCAL_STORAGE_KEY';
type Entries = Array<{
row: TableBodyRow,
entry: DeviceLogEntry,
}>;
type LogsState = {|
initialising: boolean,
rows: Array<TableBodyRow>,
entries: Entries,
key2entry: {[key: string]: DeviceLogEntry},
highlightedRows: Array<string>,
counters: Array<Counter>,
|};
const Icon = Glyph.extends({
marginTop: 5,
});
function getLineCount(str: string): number {
let count = 1;
for (let i = 0; i < str.length; i++) {
if (str[i] === '\n') {
count++;
}
}
return count;
}
function keepKeys(obj, keys) {
const result = {};
for (const key in obj) {
if (keys.includes(key)) {
result[key] = obj[key];
}
}
return result;
}
const COLUMN_SIZE = {
type: 32,
time: 120,
pid: 60,
tid: 60,
tag: 120,
app: 200,
message: 'flex',
};
const COLUMNS = {
type: {
value: '',
},
time: {
value: 'Time',
},
pid: {
value: 'PID',
},
tid: {
value: 'TID',
},
tag: {
value: 'Tag',
},
app: {
value: 'App',
},
message: {
value: 'Message',
},
};
const INITIAL_COLUMN_ORDER = [
{
key: 'type',
visible: true,
},
{
key: 'time',
visible: false,
},
{
key: 'pid',
visible: false,
},
{
key: 'tid',
visible: false,
},
{
key: 'tag',
visible: true,
},
{
key: 'app',
visible: true,
},
{
key: 'message',
visible: true,
},
];
const LOG_TYPES: {
[level: string]: {
label: string,
color: string,
icon?: React.Node,
style?: Object,
},
} = {
verbose: {
label: 'Verbose',
color: colors.purple,
},
debug: {
label: 'Debug',
color: colors.grey,
},
info: {
label: 'Info',
icon: <Icon name="info-circle" color={colors.cyan} />,
color: colors.cyan,
},
warn: {
label: 'Warn',
style: {
backgroundColor: colors.yellowTint,
color: colors.yellow,
fontWeight: 500,
},
icon: <Icon name="caution-triangle" color={colors.yellow} />,
color: colors.yellow,
},
error: {
label: 'Error',
style: {
backgroundColor: colors.redTint,
color: colors.red,
fontWeight: 500,
},
icon: <Icon name="caution-octagon" color={colors.red} />,
color: colors.red,
},
fatal: {
label: 'Fatal',
style: {
backgroundColor: colors.redTint,
color: colors.red,
fontWeight: 700,
},
icon: <Icon name="stop" color={colors.red} />,
color: colors.red,
},
};
const DEFAULT_FILTERS = [
{
type: 'enum',
enum: Object.keys(LOG_TYPES).map(value => ({
label: LOG_TYPES[value].label,
value,
})),
key: 'type',
value: [],
persistent: true,
},
];
const NonSelectableText = Text.extends({
alignSelf: 'baseline',
userSelect: 'none',
lineHeight: '130%',
marginTop: 6,
});
const LogCount = NonSelectableText.extends(
{
backgroundColor: props => props.color,
borderRadius: '999em',
fontSize: 11,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
marginTop: 4,
width: 16,
height: 16,
color: colors.white,
},
{
ignoreAttributes: ['color'],
},
);
const HiddenScrollText = NonSelectableText.extends({
'&::-webkit-scrollbar': {
display: 'none',
},
});
function pad(chunk: mixed, len: number): string {
let str = String(chunk);
while (str.length < len) {
str = `0${str}`;
}
return str;
}
export default class LogTable extends SonarDevicePlugin<LogsState> {
static id = 'DeviceLogs';
static title = 'Logs';
static icon = 'arrow-right';
static keyboardActions = ['clear', 'goToBottom', 'createPaste'];
onKeyboardAction = (action: string) => {
if (action === 'clear') {
this.clearLogs();
} else if (action === 'goToBottom') {
this.goToBottom();
} else if (action === 'createPaste') {
this.createPaste();
}
};
restoreSavedCounters = (): Array<Counter> => {
const savedCounters =
window.localStorage.getItem(LOG_WATCHER_LOCAL_STORAGE_KEY) || '[]';
return JSON.parse(savedCounters).map((counter: Counter) => ({
...counter,
expression: new RegExp(counter.label, 'gi'),
count: 0,
}));
};
state = {
rows: [],
entries: [],
key2entry: {},
initialising: true,
highlightedRows: [],
counters: this.restoreSavedCounters(),
};
tableRef: ?ManagedTable;
columns: TableColumns;
columnSizes: TableColumnSizes;
columnOrder: TableColumnOrder;
init() {
let batch: Entries = [];
let queued = false;
let counter = 0;
const supportedColumns = this.device.supportedColumns();
this.columns = keepKeys(COLUMNS, supportedColumns);
this.columnSizes = keepKeys(COLUMN_SIZE, supportedColumns);
this.columnOrder = INITIAL_COLUMN_ORDER.filter(obj =>
supportedColumns.includes(obj.key),
);
this.device.addLogListener((entry: DeviceLogEntry) => {
const {icon, style} =
LOG_TYPES[(entry.type: string)] || LOG_TYPES.verbose;
// clean message
const message = entry.message.trim();
entry.type === 'error';
let counterUpdated = false;
const counters = this.state.counters.map(counter => {
if (message.match(counter.expression)) {
counterUpdated = true;
if (counter.notify) {
new window.Notification(`${counter.label}`, {
body: 'The watched log message appeared',
});
}
return {
...counter,
count: counter.count + 1,
};
} else {
return counter;
}
});
if (counterUpdated) {
this.setState({counters});
}
// build the item, it will either be batched or added straight away
const item = {
entry,
row: {
columns: {
type: {
value: icon,
},
time: {
value: (
<HiddenScrollText code={true}>
{entry.date.toTimeString().split(' ')[0] +
'.' +
pad(entry.date.getMilliseconds(), 3)}
</HiddenScrollText>
),
},
message: {
value: <HiddenScrollText code={true}>{message}</HiddenScrollText>,
},
tag: {
value: (
<NonSelectableText code={true}>{entry.tag}</NonSelectableText>
),
isFilterable: true,
},
pid: {
value: (
<NonSelectableText code={true}>
{String(entry.pid)}
</NonSelectableText>
),
isFilterable: true,
},
tid: {
value: (
<NonSelectableText code={true}>
{String(entry.tid)}
</NonSelectableText>
),
isFilterable: true,
},
app: {
value: (
<NonSelectableText code={true}>{entry.app}</NonSelectableText>
),
isFilterable: true,
},
},
height: getLineCount(message) * 15 + 10, // 15px per line height + 8px padding
style,
type: entry.type,
filterValue: entry.message,
key: String(counter++),
},
};
// batch up logs to be processed every 250ms, if we have lots of log
// messages coming in, then calling an setState 200+ times is actually
// pretty expensive
batch.push(item);
if (!queued) {
queued = true;
setTimeout(() => {
const thisBatch = batch;
batch = [];
queued = false;
// update rows/entries
this.setState(state => {
const rows = [...state.rows];
const entries = [...state.entries];
const key2entry = {...state.key2entry};
for (let i = 0; i < thisBatch.length; i++) {
const {entry, row} = thisBatch[i];
entries.push({row, entry});
key2entry[row.key] = entry;
let previousEntry: ?DeviceLogEntry = null;
if (i > 0) {
previousEntry = thisBatch[i - 1].entry;
} else if (state.rows.length > 0 && state.entries.length > 0) {
previousEntry = state.entries[state.entries.length - 1].entry;
}
this.addRowIfNeeded(rows, row, entry, previousEntry);
}
return {
entries,
rows,
key2entry,
};
});
}, 100);
}
});
setTimeout(() => {
this.setState({
initialising: false,
});
}, 2000);
}
addRowIfNeeded(
rows: Array<TableBodyRow>,
row: TableBodyRow,
entry: DeviceLogEntry,
previousEntry: ?DeviceLogEntry,
) {
const previousRow = rows.length > 0 ? rows[rows.length - 1] : null;
if (
previousRow &&
previousEntry &&
entry.message === previousEntry.message &&
entry.tag === previousEntry.tag &&
previousRow.type != null
) {
const count = (previousRow.columns.time.value.props.count || 1) + 1;
previousRow.columns.type.value = (
<LogCount color={LOG_TYPES[previousRow.type].color}>{count}</LogCount>
);
} else {
rows.push(row);
}
}
clearLogs = () => {
this.setState({
entries: [],
rows: [],
highlightedRows: [],
key2entry: {},
counters: this.state.counters.map(counter => ({
...counter,
count: 0,
})),
});
};
createPaste = () => {
let paste = '';
const mapFn = row =>
Object.keys(COLUMNS)
.map(key => textContent(row.columns[key].value))
.join('\t');
if (this.state.highlightedRows.length > 0) {
// create paste from selection
paste = this.state.rows
.filter(row => this.state.highlightedRows.indexOf(row.key) > -1)
.map(mapFn)
.join('\n');
} else {
// create paste with all rows
paste = this.state.rows.map(mapFn).join('\n');
}
createPaste(paste);
};
setTableRef = (ref: React.ElementRef<*>) => {
this.tableRef = ref;
};
goToBottom = () => {
if (this.tableRef != null) {
this.tableRef.scrollToBottom();
}
};
onRowHighlighted = (highlightedRows: Array<string>) => {
this.setState({
...this.state,
highlightedRows,
});
};
renderSidebar = () => {
return (
<LogWatcher
counters={this.state.counters}
onChange={counters =>
this.setState({counters}, () =>
window.localStorage.setItem(
LOG_WATCHER_LOCAL_STORAGE_KEY,
JSON.stringify(this.state.counters),
),
)
}
/>
);
};
static ContextMenu = ContextMenu.extends({
flex: 1,
});
render() {
const {initialising, highlightedRows, rows} = this.state;
const contextMenuItems = [
{
type: 'separator',
},
{
label: 'Clear all',
click: this.clearLogs,
},
];
return initialising ? (
<FlexCenter fill={true}>
<LoadingIndicator />
</FlexCenter>
) : (
<LogTable.ContextMenu items={contextMenuItems} component={FlexColumn}>
<SearchableTable
innerRef={this.setTableRef}
floating={false}
multiline={true}
columnSizes={this.columnSizes}
columnOrder={this.columnOrder}
columns={this.columns}
stickyBottom={highlightedRows.length === 0}
rows={rows}
onRowHighlighted={this.onRowHighlighted}
multiHighlight={true}
defaultFilters={DEFAULT_FILTERS}
zebra={false}
actions={<Button onClick={this.clearLogs}>Clear Logs</Button>}
/>
</LogTable.ContextMenu>
);
}
}

View File

@@ -0,0 +1,216 @@
/**
* Copyright 2018-present Facebook.
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
* @format
*/
import {
PureComponent,
FlexColumn,
Panel,
Input,
Toolbar,
Text,
ManagedTable,
Button,
colors,
} from 'sonar';
export type Counter = {
expression: RegExp,
count: number,
notify: boolean,
label: string,
};
type Props = {|
onChange: (counters: Array<Counter>) => void,
counters: Array<Counter>,
|};
type State = {
input: string,
highlightedRow: ?string,
};
const ColumnSizes = {
expression: '70%',
count: '15%',
notify: 'flex',
};
const Columns = {
expression: {
value: 'Expression',
resizable: false,
},
count: {
value: 'Count',
resizable: false,
},
notify: {
value: 'Notify',
resizable: false,
},
};
const Count = Text.extends({
alignSelf: 'center',
background: colors.macOSHighlightActive,
color: colors.white,
fontSize: 12,
fontWeight: 500,
textAlign: 'center',
borderRadius: '999em',
padding: '4px 9px 3px',
lineHeight: '100%',
marginLeft: 'auto',
});
const Checkbox = Input.extends({
lineHeight: '100%',
padding: 0,
margin: 0,
height: 'auto',
alignSelf: 'center',
});
const ExpressionInput = Input.extends({
flexGrow: 1,
});
const WatcherPanel = Panel.extends({
minHeight: 200,
});
export default class LogWatcher extends PureComponent<Props, State> {
state = {
input: '',
highlightedRow: null,
};
_inputRef: ?HTMLInputElement;
onAdd = () => {
if (
this.props.counters.findIndex(({label}) => label === this.state.input) >
-1
) {
// prevent duplicates
return;
}
this.props.onChange([
...this.props.counters,
{
label: this.state.input,
expression: new RegExp(this.state.input, 'gi'),
notify: false,
count: 0,
},
]);
this.setState({input: ''});
};
onChange = (e: SyntheticInputEvent<HTMLInputElement>) => {
this.setState({
input: e.target.value,
});
};
resetCount = (index: number) => {
const newCounters = [...this.props.counters];
newCounters[index] = {
...newCounters[index],
count: 0,
};
this.props.onChange(newCounters);
};
buildRows = () => {
return this.props.counters.map(({label, count, notify}, i) => ({
columns: {
expression: {
value: <Text code={true}>{label}</Text>,
},
count: {
value: <Count onClick={() => this.resetCount(i)}>{count}</Count>,
},
notify: {
value: (
<Checkbox
type="checkbox"
checked={notify}
onChange={() => this.setNotification(i, !notify)}
/>
),
},
},
key: label,
}));
};
setNotification = (index: number, notify: boolean) => {
const newCounters: Array<Counter> = [...this.props.counters];
newCounters[index] = {
...newCounters[index],
notify,
};
this.props.onChange(newCounters);
};
onRowHighlighted = (rows: Array<string>) => {
this.setState({
highlightedRow: rows.length === 1 ? rows[0] : null,
});
};
onKeyDown = (e: SyntheticKeyboardEvent<>) => {
if (
(e.key === 'Delete' || e.key === 'Backspace') &&
this.state.highlightedRow != null
) {
this.props.onChange(
this.props.counters.filter(
({label}) => label !== this.state.highlightedRow,
),
);
}
};
onSubmit = (e: SyntheticKeyboardEvent<>) => {
if (e.key === 'Enter') {
this.onAdd();
}
};
render() {
return (
<FlexColumn fill={true} tabIndex={-1} onKeyDown={this.onKeyDown}>
<WatcherPanel
heading="Expression Watcher"
floating={false}
padded={false}>
<Toolbar>
<ExpressionInput
value={this.state.input}
placeholder="Expression..."
onChange={this.onChange}
onKeyDown={this.onSubmit}
/>
<Button onClick={this.onAdd}>Add counter</Button>
</Toolbar>
<ManagedTable
onRowHighlighted={this.onRowHighlighted}
columnSizes={ColumnSizes}
columns={Columns}
rows={this.buildRows()}
autoHeight={true}
floating={false}
zebra={false}
/>
</WatcherPanel>
</FlexColumn>
);
}
}

View File

@@ -0,0 +1,8 @@
/**
* Copyright 2018-present Facebook.
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
* @format
*/
import LogTable from './LogTable.js';
export default LogTable;

View File

@@ -0,0 +1,282 @@
/**
* Copyright 2018-present Facebook.
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
* @format
*/
import {SonarDevicePlugin} from 'sonar';
import {
Button,
FlexColumn,
FlexRow,
LoadingIndicator,
styled,
colors,
Component,
} from 'sonar';
const os = require('os');
const fs = require('fs');
const adb = require('adbkit-fb');
const path = require('path');
const exec = require('child_process').exec;
const SCREENSHOT_FILE_NAME = 'screen.png';
const VIDEO_FILE_NAME = 'video.mp4';
const SCREENSHOT_PATH = path.join(
os.homedir(),
'/.sonar/',
SCREENSHOT_FILE_NAME,
);
const VIDEO_PATH = path.join(os.homedir(), '.sonar', VIDEO_FILE_NAME);
type AndroidDevice = any;
type AdbClient = any;
type PullTransfer = any;
type State = {|
pullingData: boolean,
recording: boolean,
recordingEnabled: boolean,
capturingScreenshot: boolean,
|};
const BigButton = Button.extends({
height: 200,
width: 200,
flexGrow: 1,
fontSize: 24,
});
const ButtonContainer = FlexRow.extends({
alignItems: 'center',
justifyContent: 'space-around',
padding: 20,
});
const LoadingSpinnerContainer = FlexRow.extends({
flexGrow: 1,
padding: 24,
justifyContent: 'center',
alignItems: 'center',
});
const LoadingSpinnerText = styled.text({
fontSize: 24,
marginLeft: 12,
color: colors.grey,
});
class LoadingSpinner extends Component<{}, {}> {
render() {
return (
<LoadingSpinnerContainer>
<LoadingIndicator />
<LoadingSpinnerText>Pulling files from device...</LoadingSpinnerText>
</LoadingSpinnerContainer>
);
}
}
function openFile(path: string): Promise<*> {
return new Promise((resolve, reject) => {
exec(`${getOpenCommand()} ${path}`, (error, stdout, stderr) => {
if (error) {
reject(error);
} else {
resolve(path);
}
});
});
}
function getOpenCommand(): string {
//TODO: TESTED ONLY ON MAC!
switch (os.platform()) {
case 'win32':
return 'start';
case 'linux':
return 'xdg-open';
default:
return 'open';
}
}
function writePngStreamToFile(stream: PullTransfer): Promise<string> {
return new Promise((resolve, reject) => {
stream.on('end', () => {
resolve(SCREENSHOT_PATH);
});
stream.on('error', reject);
stream.pipe(fs.createWriteStream(SCREENSHOT_PATH));
});
}
export default class ScreenPlugin extends SonarDevicePlugin<State> {
static id = 'DeviceScreen';
static title = 'Screen';
static icon = 'mobile';
device: AndroidDevice;
adbClient: AdbClient;
init() {
this.adbClient = this.device.adb;
this.executeShell(
`[ ! -f /system/bin/screenrecord ] && echo "File does not exist"`,
).then(output => {
if (output) {
console.error(
'screenrecord util does not exist. Most likely it is an emulator which does not support screen recording via adb',
);
this.setState({
recordingEnabled: false,
});
} else {
this.setState({
recordingEnabled: true,
});
}
});
}
captureScreenshot = () => {
return this.adbClient
.screencap(this.device.serial)
.then(writePngStreamToFile)
.then(openFile)
.catch(error => {
//TODO: proper logging?
console.error(error);
});
};
pullFromDevice = (src: string, dst: string): Promise<string> => {
return new Promise((resolve, reject) => {
return this.adbClient.pull(this.device.serial, src).then(stream => {
stream.on('end', () => {
resolve(dst);
});
stream.on('error', reject);
stream.pipe(fs.createWriteStream(dst));
});
});
};
onRecordingClicked = () => {
if (this.state.recording) {
this.stopRecording();
} else {
this.startRecording();
}
};
onScreenshotClicked = () => {
var self = this;
this.setState({
capturingScreenshot: true,
});
this.captureScreenshot().then(() => {
self.setState({
capturingScreenshot: false,
});
});
};
startRecording = () => {
const self = this;
this.setState({
recording: true,
});
this.executeShell(`screenrecord --bugreport /sdcard/${VIDEO_FILE_NAME}`)
.then(output => {
if (output) {
throw output;
}
})
.then(() => {
self.setState({
recording: false,
pullingData: true,
});
})
.then((): Promise<string> => {
return self.pullFromDevice(`/sdcard/${VIDEO_FILE_NAME}`, VIDEO_PATH);
})
.then(openFile)
.then(() => {
self.executeShell(`rm /sdcard/${VIDEO_FILE_NAME}`);
})
.then(() => {
self.setState({
pullingData: false,
});
})
.catch(error => {
console.error(`unable to capture video: ${error}`);
self.setState({
recording: false,
pullingData: false,
});
});
};
stopRecording = () => {
this.executeShell(`pgrep 'screenrecord' -L 2`);
};
executeShell = (command: string): Promise<string> => {
return this.adbClient
.shell(this.device.serial, command)
.then(adb.util.readAll)
.then(output => {
return new Promise((resolve, reject) => {
resolve(output.toString().trim());
});
});
};
getLoadingSpinner = () => {
return this.state.pullingData ? <LoadingSpinner /> : null;
};
render() {
const recordingEnabled =
this.state.recordingEnabled &&
!this.state.capturingScreenshot &&
!this.state.pullingData;
const screenshotEnabled =
!this.state.recording &&
!this.state.capturingScreenshot &&
!this.state.pullingData;
return (
<FlexColumn>
<ButtonContainer>
<BigButton
key="video_btn"
onClick={!recordingEnabled ? null : this.onRecordingClicked}
icon={this.state.recording ? 'stop' : 'camcorder'}
disabled={!recordingEnabled}
selected={true}
pulse={this.state.recording}
iconSize={24}>
{!this.state.recording ? 'Record screen' : 'Stop recording'}
</BigButton>
<BigButton
key="screenshot_btn"
icon="camera"
selected={true}
onClick={!screenshotEnabled ? null : this.onScreenshotClicked}
iconSize={24}
pulse={this.state.capturingScreenshot}
disabled={!screenshotEnabled}>
Take screenshot
</BigButton>
</ButtonContainer>
{this.getLoadingSpinner()}
</FlexColumn>
);
}
}