Move plugins to "sonar/desktop/plugins"

Summary:
Plugins moved from "sonar/desktop/src/plugins" to "sonar/desktop/plugins".

Fixed all the paths after moving.

New "desktop" folder structure:
- `src` - Flipper desktop app JS code executing in Electron Renderer (Chrome) process.
- `static` - Flipper desktop app JS code executing in Electron Main (Node.js) process.
- `plugins` - Flipper desktop JS plugins.
- `pkg` - Flipper packaging lib and CLI tool.
- `doctor` - Flipper diagnostics lib and CLI tool.
- `scripts` - Build scripts for Flipper desktop app.
- `headless` - Headless version of Flipper desktop app.
- `headless-tests` - Integration tests running agains Flipper headless version.

Reviewed By: mweststrate

Differential Revision: D20344186

fbshipit-source-id: d020da970b2ea1e001f9061a8782bfeb54e31ba0
This commit is contained in:
Anton Nikolaev
2020-03-14 14:26:07 -07:00
committed by Facebook GitHub Bot
parent beb5c85e69
commit 10d990c32c
133 changed files with 106 additions and 77 deletions

View File

@@ -0,0 +1,78 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {ImageId, ImageData} from './api';
export type ImagesMap = {[imageId in ImageId]: ImageData};
const maxInflightRequests = 10;
export default class ImagePool {
cache: ImagesMap = {};
requested: {[imageId in ImageId]: boolean} = {};
queued: Array<ImageId> = [];
inFlightRequests: number = 0;
fetchImage: (imageId: ImageId) => void;
updateNotificationScheduled: boolean = false;
onPoolUpdated: (images: ImagesMap) => void;
constructor(
fetchImage: (imageId: ImageId) => void,
onPoolUpdated: (images: ImagesMap) => void,
) {
this.fetchImage = fetchImage;
this.onPoolUpdated = onPoolUpdated;
}
getImages(): ImagesMap {
return {...this.cache};
}
fetchImages(ids: Array<string>) {
for (const id of ids) {
if (!this.cache[id] && !this.requested[id]) {
this.requested[id] = true;
if (this.inFlightRequests < maxInflightRequests) {
this.inFlightRequests++;
this.fetchImage(id);
} else {
this.queued.unshift(id);
}
}
}
}
clear() {
this.cache = {};
this.requested = {};
}
_fetchCompleted(image: ImageData): void {
this.cache[image.imageId] = image;
delete this.requested[image.imageId];
if (this.queued.length > 0) {
const popped = this.queued.pop() as string;
this.fetchImage(popped);
} else {
this.inFlightRequests--;
}
if (!this.updateNotificationScheduled) {
this.updateNotificationScheduled = true;
window.setTimeout(this._notify, 1000);
}
}
_notify = () => {
this.updateNotificationScheduled = false;
this.onPoolUpdated(this.getImages());
};
}

View File

@@ -0,0 +1,511 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {CacheInfo, ImageId, ImageData, ImagesList} from './api';
import {ImageEventWithId} from './index';
import {
Toolbar,
Button,
Spacer,
colors,
FlexBox,
FlexRow,
FlexColumn,
LoadingIndicator,
styled,
Select,
ToggleButton,
Text,
} from 'flipper';
import MultipleSelect from './MultipleSelect';
import {ImagesMap} from './ImagePool';
import {clipboard} from 'electron';
import React, {ChangeEvent, KeyboardEvent, PureComponent} from 'react';
function formatMB(bytes: number) {
return Math.floor(bytes / (1024 * 1024)) + 'MB';
}
function formatKB(bytes: number) {
return Math.floor(bytes / 1024) + 'KB';
}
type ToggleProps = {
label: string;
onClick?: (newValue: boolean) => void;
toggled: boolean;
};
const ToolbarToggleButton = styled(ToggleButton)(_props => ({
alignSelf: 'center',
marginRight: 4,
minWidth: 30,
}));
const ToggleLabel = styled(Text)(_props => ({
whiteSpace: 'nowrap',
}));
function Toggle(props: ToggleProps) {
return (
<>
<ToolbarToggleButton
onClick={() => {
props.onClick && props.onClick(!props.toggled);
}}
toggled={props.toggled}
/>
<ToggleLabel>{props.label}</ToggleLabel>
</>
);
}
type ImagesCacheOverviewProps = {
onColdStartChange: (checked: boolean) => void;
coldStartFilter: boolean;
allSurfacesOption: string;
surfaceOptions: Set<string>;
selectedSurfaces: Set<string>;
onChangeSurface: (key: Set<string>) => void;
images: ImagesList;
onClear: (type: string) => void;
onTrimMemory: () => void;
onRefresh: () => void;
onEnableDebugOverlay: (enabled: boolean) => void;
isDebugOverlayEnabled: boolean;
onEnableAutoRefresh: (enabled: boolean) => void;
isAutoRefreshEnabled: boolean;
onImageSelected: (selectedImage: ImageId) => void;
imagesMap: ImagesMap;
events: Array<ImageEventWithId>;
onTrackLeaks: (enabled: boolean) => void;
isLeakTrackingEnabled: boolean;
};
type ImagesCacheOverviewState = {
selectedImage: ImageId | null;
size: number;
};
const StyledSelect = styled(Select)(props => ({
marginLeft: 6,
marginRight: 6,
height: '100%',
maxWidth: 164,
}));
export default class ImagesCacheOverview extends PureComponent<
ImagesCacheOverviewProps,
ImagesCacheOverviewState
> {
state = {
selectedImage: null,
size: 150,
};
static Container = styled(FlexColumn)({
backgroundColor: colors.white,
});
static Content = styled(FlexColumn)({
flex: 1,
overflow: 'auto',
});
static Empty = styled(FlexBox)({
alignItems: 'center',
height: '100%',
justifyContent: 'center',
width: '100%',
});
onImageSelected = (selectedImage: ImageId) => {
this.setState({selectedImage});
this.props.onImageSelected(selectedImage);
};
onKeyDown = (e: KeyboardEvent) => {
const selectedImage = this.state.selectedImage;
const imagesMap = this.props.imagesMap;
if (selectedImage) {
if ((e.ctrlKey || e.metaKey) && e.key === 'c') {
clipboard.writeText(String(imagesMap[selectedImage]));
e.preventDefault();
}
}
};
onEnableDebugOverlayToggled = () => {
this.props.onEnableDebugOverlay(!this.props.isDebugOverlayEnabled);
};
onEnableAutoRefreshToggled = () => {
this.props.onEnableAutoRefresh(!this.props.isAutoRefreshEnabled);
};
onChangeSize = (e: ChangeEvent<HTMLInputElement>) =>
this.setState({size: parseInt(e.target.value, 10)});
onSurfaceOptionsChange = (selectedItem: string, checked: boolean) => {
const {allSurfacesOption, surfaceOptions} = this.props;
const selectedSurfaces = new Set([...this.props.selectedSurfaces]);
if (checked && selectedItem === allSurfacesOption) {
this.props.onChangeSurface(surfaceOptions);
return;
}
if (!checked && selectedSurfaces.size === 1) {
return;
}
if (selectedItem !== allSurfacesOption) {
selectedSurfaces.delete(allSurfacesOption);
if (checked) {
selectedSurfaces.add(selectedItem);
} else {
selectedSurfaces.delete(selectedItem);
}
}
if (
surfaceOptions.size - selectedSurfaces.size === 1 &&
!selectedSurfaces.has(allSurfacesOption)
) {
selectedSurfaces.add(allSurfacesOption);
}
this.props.onChangeSurface(selectedSurfaces);
};
render() {
const hasImages =
this.props.images.reduce(
(c, cacheInfo) => c + cacheInfo.imageIds.length,
0,
) > 0;
return (
<ImagesCacheOverview.Container
grow={true}
onKeyDown={this.onKeyDown}
tabIndex={0}>
<Toolbar position="top">
<Button icon="trash" onClick={this.props.onTrimMemory}>
Trim Memory
</Button>
<Button onClick={this.props.onRefresh}>Refresh</Button>
<MultipleSelect
selected={this.props.selectedSurfaces}
options={this.props.surfaceOptions}
onChange={this.onSurfaceOptionsChange}
label="Surfaces"
/>
<Toggle
onClick={this.onEnableAutoRefreshToggled}
toggled={this.props.isAutoRefreshEnabled}
label="Auto Refresh"
/>
<Toggle
onClick={this.onEnableDebugOverlayToggled}
toggled={this.props.isDebugOverlayEnabled}
label="Show Debug Overlay"
/>
<Toggle
toggled={this.props.coldStartFilter}
onClick={this.props.onColdStartChange}
label="Show Cold Start Images"
/>
<Toggle
toggled={this.props.isLeakTrackingEnabled}
onClick={this.props.onTrackLeaks}
label="Track Leaks"
/>
<Spacer />
<input
type="range"
onChange={this.onChangeSize}
min={50}
max={150}
value={this.state.size}
/>
</Toolbar>
{!hasImages ? (
<ImagesCacheOverview.Empty>
<LoadingIndicator size={50} />
</ImagesCacheOverview.Empty>
) : (
<ImagesCacheOverview.Content>
{this.props.images.map((data: CacheInfo, index: number) => {
const maxSize = data.maxSizeBytes;
const subtitle = maxSize
? formatMB(data.sizeBytes) + ' / ' + formatMB(maxSize)
: formatMB(data.sizeBytes);
const onClear =
data.clearKey !== undefined
? () => this.props.onClear(data.clearKey as string)
: undefined;
return (
<ImageGrid
key={index}
title={data.cacheType}
subtitle={subtitle}
images={data.imageIds}
onImageSelected={this.onImageSelected}
selectedImage={this.state.selectedImage}
imagesMap={this.props.imagesMap}
size={this.state.size}
events={this.props.events}
onClear={onClear}
/>
);
})}
</ImagesCacheOverview.Content>
)}
</ImagesCacheOverview.Container>
);
}
}
class ImageGrid extends PureComponent<{
title: string;
subtitle: string;
images: Array<ImageId>;
selectedImage: ImageId | null;
onImageSelected: (image: ImageId) => void;
onClear: (() => void) | undefined;
imagesMap: ImagesMap;
size: number;
events: Array<ImageEventWithId>;
}> {
static Content = styled.div({
paddingLeft: 15,
});
render() {
const {images, onImageSelected, selectedImage} = this.props;
if (images.length === 0) {
return null;
}
return [
<ImageGridHeader
key="header"
title={this.props.title}
subtitle={this.props.subtitle}
onClear={this.props.onClear}
/>,
<ImageGrid.Content key="content">
{images.map(imageId => (
<ImageItem
imageId={imageId}
image={this.props.imagesMap[imageId]}
key={imageId}
selected={selectedImage != null && selectedImage === imageId}
onSelected={onImageSelected}
size={this.props.size}
numberOfRequests={
this.props.events.filter(e => e.imageIds.includes(imageId)).length
}
/>
))}
</ImageGrid.Content>,
];
}
}
class ImageGridHeader extends PureComponent<{
title: string;
subtitle: string;
onClear: (() => void) | undefined;
}> {
static Container = styled(FlexRow)({
color: colors.dark70,
paddingTop: 10,
paddingBottom: 10,
marginLeft: 15,
marginRight: 15,
marginBottom: 15,
borderBottom: `1px solid ${colors.light10}`,
flexShrink: 0,
alignItems: 'center',
position: 'sticky',
top: 0,
left: 0,
right: 0,
backgroundColor: colors.white,
zIndex: 3,
});
static Heading = styled.span({
fontSize: 22,
fontWeight: 600,
});
static Subtitle = styled.span({
fontSize: 22,
fontWeight: 300,
marginLeft: 15,
});
static ClearButton = styled(Button)({
alignSelf: 'center',
height: 30,
marginLeft: 'auto',
width: 100,
});
render() {
return (
<ImageGridHeader.Container>
<ImageGridHeader.Heading>{this.props.title}</ImageGridHeader.Heading>
<ImageGridHeader.Subtitle>
{this.props.subtitle}
</ImageGridHeader.Subtitle>
<Spacer />
{this.props.onClear ? (
<ImageGridHeader.ClearButton onClick={this.props.onClear}>
Clear Cache
</ImageGridHeader.ClearButton>
) : null}
</ImageGridHeader.Container>
);
}
}
class ImageItem extends PureComponent<{
imageId: ImageId;
image: ImageData;
selected: boolean;
onSelected: (image: ImageId) => void;
size: number;
numberOfRequests: number;
}> {
static Container = styled(FlexBox)<{size: number}>(props => ({
float: 'left',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
height: props.size,
width: props.size,
borderRadius: 4,
marginRight: 15,
marginBottom: 15,
backgroundColor: colors.light02,
}));
static Image = styled.img({
borderRadius: 4,
maxHeight: '100%',
maxWidth: '100%',
objectFit: 'contain',
});
static Loading = styled.span({
padding: '0 0',
});
static SelectedHighlight = styled.div<{selected: boolean}>(props => ({
borderColor: colors.highlight,
borderStyle: 'solid',
borderWidth: props.selected ? 3 : 0,
borderRadius: 4,
boxShadow: props.selected ? `inset 0 0 0 1px ${colors.white}` : 'none',
bottom: 0,
left: 0,
position: 'absolute',
right: 0,
top: 0,
}));
static HoverOverlay = styled(FlexColumn)<{selected: boolean; size: number}>(
props => ({
alignItems: 'center',
backgroundColor: colors.whiteAlpha80,
bottom: props.selected ? 4 : 0,
fontSize: props.size > 100 ? 16 : 11,
justifyContent: 'center',
left: props.selected ? 4 : 0,
opacity: 0,
position: 'absolute',
right: props.selected ? 4 : 0,
top: props.selected ? 4 : 0,
overflow: 'hidden',
transition: '.1s opacity',
'&:hover': {
opacity: 1,
},
}),
);
static MemoryLabel = styled.span({
fontWeight: 600,
marginBottom: 6,
});
static SizeLabel = styled.span({
fontWeight: 300,
});
static Events = styled.div({
position: 'absolute',
top: -5,
right: -5,
color: colors.white,
backgroundColor: colors.highlight,
fontWeight: 600,
borderRadius: 10,
fontSize: '0.85em',
zIndex: 2,
lineHeight: '20px',
width: 20,
textAlign: 'center',
});
static defaultProps = {
size: 150,
};
onClick = () => {
this.props.onSelected(this.props.imageId);
};
render() {
const {image, selected, size, numberOfRequests} = this.props;
return (
<ImageItem.Container onClick={this.onClick} size={size}>
{numberOfRequests > 0 && image != null && (
<ImageItem.Events>{numberOfRequests}</ImageItem.Events>
)}
{image != null ? (
<ImageItem.Image src={image.data} />
) : (
<LoadingIndicator size={25} />
)}
<ImageItem.SelectedHighlight selected={selected} />
{image != null && (
<ImageItem.HoverOverlay selected={selected} size={size}>
<ImageItem.MemoryLabel>
{formatKB(image.sizeBytes)}
</ImageItem.MemoryLabel>
<ImageItem.SizeLabel>
{image.width}&times;{image.height}
</ImageItem.SizeLabel>
</ImageItem.HoverOverlay>
)}
</ImageItem.Container>
);
}
}

View File

@@ -0,0 +1,193 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {ImageData} from './api';
import {ImageEventWithId} from './index';
import {
Component,
ContextMenu,
DataDescription,
Text,
Panel,
ManagedDataInspector,
FlexColumn,
FlexRow,
colors,
styled,
} from 'flipper';
import React from 'react';
import {clipboard, MenuItemConstructorOptions} from 'electron';
type ImagesSidebarProps = {
image: ImageData;
events: Array<ImageEventWithId>;
};
type ImagesSidebarState = {};
const DataDescriptionKey = styled.span({
color: colors.grapeDark1,
});
const WordBreakFlexColumn = styled(FlexColumn)({
wordBreak: 'break-all',
});
export default class ImagesSidebar extends Component<
ImagesSidebarProps,
ImagesSidebarState
> {
render() {
return (
<div>
{this.renderUri()}
{this.props.events.map(e => (
<EventDetails key={e.eventId} event={e} />
))}
</div>
);
}
renderUri() {
if (!this.props.image) {
return null;
}
if (!this.props.image.uri) {
return null;
}
const contextMenuItems: MenuItemConstructorOptions[] = [
{
label: 'Copy URI',
click: () => clipboard.writeText(this.props.image.uri!),
},
];
return (
<Panel heading="Sources" floating={false}>
<FlexRow>
<FlexColumn>
<DataDescriptionKey>URI</DataDescriptionKey>
</FlexColumn>
<FlexColumn>
<span key="sep">:&nbsp;</span>
</FlexColumn>
<WordBreakFlexColumn>
<ContextMenu component="span" items={contextMenuItems}>
<DataDescription
type="string"
value={this.props.image.uri}
setValue={null}
/>
</ContextMenu>
</WordBreakFlexColumn>
</FlexRow>
</Panel>
);
}
}
class EventDetails extends Component<{
event: ImageEventWithId;
}> {
render() {
const {event} = this.props;
return (
<Panel
heading={<RequestHeader event={event} />}
floating={false}
padded={true}>
<p>
<DataDescriptionKey>Attribution</DataDescriptionKey>
<span key="sep">: </span>
<ManagedDataInspector data={event.attribution} />
</p>
<p>
<DataDescriptionKey>Time start</DataDescriptionKey>
<span key="sep">: </span>
<DataDescription
type="number"
value={event.startTime}
setValue={function(path: Array<string>, val: any) {}}
/>
</p>
<p>
<DataDescriptionKey>Time end</DataDescriptionKey>
<span key="sep">: </span>
<DataDescription
type="number"
value={event.endTime}
setValue={function(path: Array<string>, val: any) {}}
/>
</p>
<p>
<DataDescriptionKey>Source</DataDescriptionKey>
<span key="sep">: </span>
<DataDescription
type="string"
value={event.source}
setValue={function(path: Array<string>, val: any) {}}
/>
</p>
<p>
<DataDescriptionKey>Requested on cold start</DataDescriptionKey>
<span key="sep">: </span>
<DataDescription
type="boolean"
value={event.coldStart}
setValue={null}
/>
</p>
{this.renderViewportData()}
</Panel>
);
}
renderViewportData() {
const viewport = this.props.event.viewport;
if (!viewport) {
return null;
}
return (
<p>
<DataDescriptionKey>Viewport</DataDescriptionKey>
<span key="sep">: </span>
<DataDescription
type="string"
value={viewport.width + 'x' + viewport.height}
setValue={function(path: Array<string>, val: any) {}}
/>
</p>
);
// TODO (t31947746): grey box time, n-th scan time
}
}
class RequestHeader extends Component<{
event: ImageEventWithId;
}> {
dateString = (timestamp: number) => {
const date = new Date(timestamp);
return `${date.toTimeString().split(' ')[0]}.${(
'000' + date.getMilliseconds()
).substr(-3)}`;
};
render() {
const {event} = this.props;
const durationMs = event.endTime - event.startTime;
return (
<Text>
{event.viewport ? 'Request' : 'Prefetch'} at{' '}
{this.dateString(event.startTime)} ({durationMs}ms)
</Text>
);
}
}

View File

@@ -0,0 +1,111 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {Block, Button, colors, FlexColumn, styled, Glyph} from 'flipper';
import React, {ChangeEvent, Component} from 'react';
const Container = styled(Block)({
position: 'relative',
marginLeft: '10px',
});
const List = styled(FlexColumn)<{visibleList: boolean}>(props => ({
display: props.visibleList ? 'flex' : 'none',
position: 'absolute',
top: '32px',
left: 0,
zIndex: 4,
width: 'auto',
minWidth: '200px',
backgroundColor: colors.white,
borderWidth: '1px',
borderStyle: 'solid',
borderColor: colors.macOSTitleBarButtonBorderBottom,
borderRadius: 4,
}));
const ListItem = styled.label({
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
width: '100%',
color: colors.light50,
fontSize: '11px',
padding: '0 5px',
'&:hover': {
backgroundColor: colors.macOSTitleBarButtonBackgroundActiveHighlight,
},
});
const Checkbox = styled.input({
display: 'inline-block',
marginRight: 5,
verticalAlign: 'middle',
});
const StyledGlyph = styled(Glyph)({
marginLeft: '4px',
});
type State = {
visibleList: boolean;
};
export default class MultipleSelect extends Component<
{
selected: Set<string>;
options: Set<string>;
onChange: (selectedItem: string, checked: boolean) => void;
label: string;
},
State
> {
state = {
visibleList: false,
};
handleOnChange = (event: ChangeEvent<HTMLInputElement>) => {
const {
target: {value, checked},
} = event;
this.props.onChange(value, checked);
};
toggleList = () => this.setState({visibleList: !this.state.visibleList});
render() {
const {selected, label, options} = this.props;
const {visibleList} = this.state;
const icon = visibleList ? 'chevron-up' : 'chevron-down';
return (
<Container>
<Button onClick={this.toggleList}>
{label} <StyledGlyph name={icon} />
</Button>
<List visibleList={visibleList}>
{Array.from(options).map((option, index) => (
<ListItem key={index}>
<Checkbox
onChange={this.handleOnChange}
checked={selected.has(option)}
value={option}
type="checkbox"
/>
{option}
</ListItem>
))}
</List>
</Container>
);
}
}

View File

@@ -0,0 +1,32 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`notifications for leaks 1`] = `
<React.Fragment>
<Styled(div)>
CloseableReference leaked for
<Text
code={true}
>
com.facebook.imagepipeline.memory.NativeMemoryChunk
</Text>
(identity hashcode:
deadbeef
).
</Styled(div)>
<Styled(div)>
<Text
bold={true}
>
Stacktrace:
</Text>
</Styled(div)>
<Styled(div)>
<Text
code={true}
>
&lt;unavailable&gt;
</Text>
</Styled(div)>
</React.Fragment>
`;

View File

@@ -0,0 +1,324 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import FrescoPlugin from '../index';
import {PersistedState, ImageEventWithId} from '../index';
import {AndroidCloseableReferenceLeakEvent} from '../api';
import {MetricType, Notification} from 'flipper';
import {ImagesMap} from '../ImagePool';
type ScanDisplayTime = {[scan_number: number]: number};
function mockPersistedState(
imageSizes: Array<{
width: number;
height: number;
}> = [],
viewport: {
width: number;
height: number;
} = {width: 150, height: 150},
): PersistedState {
const scanDisplayTime: ScanDisplayTime = {};
scanDisplayTime[1] = 3;
const events: Array<ImageEventWithId> = [
{
imageIds: [...Array(imageSizes.length).keys()].map(String),
eventId: 0,
attribution: [],
startTime: 1,
endTime: 2,
source: 'source',
coldStart: true,
viewport: {...viewport, scanDisplayTime},
},
];
const imagesMap = imageSizes.reduce((acc, val, index) => {
acc[index] = {
imageId: String(index),
width: val.width,
height: val.height,
sizeBytes: 10,
data: 'undefined',
};
return acc;
}, {} as ImagesMap);
return {
surfaceList: new Set(),
images: [],
events,
imagesMap,
closeableReferenceLeaks: [],
isLeakTrackingEnabled: false,
nextEventId: 0,
};
}
test('the metric reducer for the input having regression', () => {
const persistedState = mockPersistedState(
[
{
width: 150,
height: 150,
},
{
width: 150,
height: 150,
},
{
width: 150,
height: 150,
},
],
{
width: 100,
height: 100,
},
);
expect(FrescoPlugin.metricsReducer).toBeDefined();
//$FlowFixMe: Added a check if the metricsReducer exists in FrescoPlugin
const metrics = FrescoPlugin.metricsReducer(persistedState);
return expect(metrics).resolves.toMatchObject({
WASTED_BYTES: 37500,
});
});
test('the metric reducer for the input having no regression', () => {
const persistedState = mockPersistedState(
[
{
width: 50,
height: 10,
},
{
width: 50,
height: 50,
},
{
width: 50,
height: 50,
},
],
{
width: 100,
height: 100,
},
);
const metricsReducer = FrescoPlugin.metricsReducer;
expect(metricsReducer).toBeDefined();
//$FlowFixMe: Added a check if the metricsReducer exists in FrescoPlugin
const metrics = metricsReducer(persistedState);
return expect(metrics).resolves.toMatchObject({
WASTED_BYTES: 0,
});
});
test('the metric reducer for the default persisted state', () => {
const metricsReducer = FrescoPlugin.metricsReducer;
expect(metricsReducer).toBeDefined();
//$FlowFixMe: Added a check if the metricsReducer exists in FrescoPlugin
const metrics = metricsReducer(FrescoPlugin.defaultPersistedState);
return expect(metrics).resolves.toMatchObject({WASTED_BYTES: 0});
});
test('the metric reducer with the events data but with no imageData in imagesMap ', () => {
const persistedState = mockPersistedState(
[
{
width: 50,
height: 10,
},
{
width: 50,
height: 50,
},
{
width: 50,
height: 50,
},
],
{
width: 100,
height: 100,
},
);
persistedState.imagesMap = {};
const metricsReducer = FrescoPlugin.metricsReducer;
expect(metricsReducer).toBeDefined();
//$FlowFixMe: Added a check if the metricsReducer exists in FrescoPlugin
const metrics = metricsReducer(persistedState);
return expect(metrics).resolves.toMatchObject({WASTED_BYTES: 0});
});
test('the metric reducer with the no viewPort data in events', () => {
const persistedState = mockPersistedState(
[
{
width: 50,
height: 10,
},
{
width: 50,
height: 50,
},
{
width: 50,
height: 50,
},
],
{
width: 100,
height: 100,
},
);
delete persistedState.events[0].viewport;
const metricsReducer = FrescoPlugin.metricsReducer;
expect(metricsReducer).toBeDefined();
//$FlowFixMe: Added a check if the metricsReducer exists in FrescoPlugin
const metrics = metricsReducer(persistedState);
return expect(metrics).resolves.toMatchObject({WASTED_BYTES: 0});
});
test('the metric reducer with the multiple events', () => {
const scanDisplayTime: ScanDisplayTime = {};
scanDisplayTime[1] = 3;
const events: Array<ImageEventWithId> = [
{
imageIds: ['0', '1'],
eventId: 0,
attribution: [],
startTime: 1,
endTime: 2,
source: 'source',
coldStart: true,
viewport: {width: 100, height: 100, scanDisplayTime},
},
{
imageIds: ['2', '3'],
eventId: 1,
attribution: [],
startTime: 1,
endTime: 2,
source: 'source',
coldStart: true,
viewport: {width: 50, height: 50, scanDisplayTime},
},
];
const imageSizes = [
{
width: 150,
height: 150,
},
{
width: 100,
height: 100,
},
{
width: 250,
height: 250,
},
{
width: 300,
height: 300,
},
];
const imagesMap = imageSizes.reduce((acc, val, index) => {
acc[index] = {
imageId: String(index),
width: val.width,
height: val.height,
sizeBytes: 10,
data: 'undefined',
};
return acc;
}, {} as ImagesMap);
const persistedState = {
surfaceList: new Set<string>(),
images: [],
nextEventId: 0,
events,
imagesMap,
closeableReferenceLeaks: [],
isLeakTrackingEnabled: true,
};
const metricsReducer = FrescoPlugin.metricsReducer;
expect(metricsReducer).toBeDefined();
//$FlowFixMe: Added a check if the metricsReducer exists in FrescoPlugin
const metrics = metricsReducer(persistedState);
return expect(metrics).resolves.toMatchObject({WASTED_BYTES: 160000});
});
test('closeable reference metrics on empty state', () => {
const metricsReducer: (
persistedState: PersistedState,
) => Promise<MetricType> = FrescoPlugin.metricsReducer;
const persistedState = mockPersistedState();
const metrics = metricsReducer(persistedState);
return expect(metrics).resolves.toMatchObject({CLOSEABLE_REFERENCE_LEAKS: 0});
});
test('closeable reference metrics on input', () => {
const metricsReducer: (
persistedState: PersistedState,
) => Promise<MetricType> = FrescoPlugin.metricsReducer;
const closeableReferenceLeaks: Array<AndroidCloseableReferenceLeakEvent> = [
{
identityHashCode: 'deadbeef',
className: 'com.facebook.imagepipeline.memory.NativeMemoryChunk',
stacktrace: null,
},
{
identityHashCode: 'f4c3b00c',
className: 'com.facebook.flipper.SomeMemoryAbstraction',
stacktrace: null,
},
];
const persistedState = {
...mockPersistedState(),
closeableReferenceLeaks,
};
const metrics = metricsReducer(persistedState);
return expect(metrics).resolves.toMatchObject({CLOSEABLE_REFERENCE_LEAKS: 2});
});
test('notifications for leaks', () => {
const notificationReducer: (
persistedState: PersistedState,
) => Array<Notification> = FrescoPlugin.getActiveNotifications;
const closeableReferenceLeaks: Array<AndroidCloseableReferenceLeakEvent> = [
{
identityHashCode: 'deadbeef',
className: 'com.facebook.imagepipeline.memory.NativeMemoryChunk',
stacktrace: null,
},
{
identityHashCode: 'f4c3b00c',
className: 'com.facebook.flipper.SomeMemoryAbstraction',
stacktrace: null,
},
];
const persistedStateWithoutTracking = {
...mockPersistedState(),
closeableReferenceLeaks,
isLeakTrackingEnabled: false,
};
const emptyNotifs = notificationReducer(persistedStateWithoutTracking);
expect(emptyNotifs).toHaveLength(0);
const persistedStateWithTracking = {
...mockPersistedState(),
closeableReferenceLeaks,
isLeakTrackingEnabled: true,
};
const notifs = notificationReducer(persistedStateWithTracking);
expect(notifs).toHaveLength(2);
expect(notifs[0].message).toMatchSnapshot();
expect(notifs[1].title).toContain('SomeMemoryAbstraction');
});

View File

@@ -0,0 +1,77 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
export type ImageId = string;
// Listing images
export type CacheInfo = {
cacheType: string;
clearKey?: string; // set this if this cache level supports clear(<key>)
sizeBytes: number;
maxSizeBytes?: number;
imageIds: Array<ImageId>;
};
export type ImagesList = Array<CacheInfo>;
// The iOS Flipper api does not support a top-level array, so we wrap it in an object
export type ImagesListResponse = {
levels: ImagesList;
};
// listImages() -> ImagesListResponse
// Getting details on a specific image
export type ImageBytes = string;
export type ImageData = {
imageId: ImageId;
uri?: string;
width: number;
height: number;
sizeBytes: number;
data: ImageBytes;
surface?: string;
};
// getImage({imageId: string}) -> ImageData
// Subscribing to image events (requests and prefetches)
export type Timestamp = number;
export type ViewportData = {
width: number;
height: number;
scanDisplayTime: {[scan_number: number]: Timestamp};
};
export type ImageEvent = {
imageIds: Array<ImageId>;
attribution: Array<string>;
startTime: Timestamp;
endTime: Timestamp;
source: string;
coldStart: boolean;
viewport?: ViewportData; // not set for prefetches
};
// Misc
export type FrescoDebugOverlayEvent = {
enabled: boolean;
};
export type AndroidCloseableReferenceLeakEvent = {
identityHashCode: string;
className: string;
stacktrace: string | null;
};

View File

@@ -0,0 +1,500 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {
ImageId,
ImageData,
ImagesList,
ImagesListResponse,
ImageEvent,
FrescoDebugOverlayEvent,
AndroidCloseableReferenceLeakEvent,
CacheInfo,
} from './api';
import {Fragment} from 'react';
import {ImagesMap} from './ImagePool';
import {MetricType, ReduxState} from 'flipper';
import React from 'react';
import ImagesCacheOverview from './ImagesCacheOverview';
import {
FlipperPlugin,
FlexRow,
Text,
DetailSidebar,
colors,
styled,
isProduction,
Notification,
BaseAction,
} from 'flipper';
import ImagesSidebar from './ImagesSidebar';
import ImagePool from './ImagePool';
export type ImageEventWithId = ImageEvent & {eventId: number};
export type PersistedState = {
surfaceList: Set<string>;
images: ImagesList;
events: Array<ImageEventWithId>;
imagesMap: ImagesMap;
closeableReferenceLeaks: Array<AndroidCloseableReferenceLeakEvent>;
isLeakTrackingEnabled: boolean;
nextEventId: number;
};
type PluginState = {
selectedSurfaces: Set<string>;
selectedImage: ImageId | null;
isDebugOverlayEnabled: boolean;
isAutoRefreshEnabled: boolean;
images: ImagesList;
coldStartFilter: boolean;
};
const EmptySidebar = styled(FlexRow)({
alignItems: 'center',
justifyContent: 'center',
color: colors.light30,
padding: 15,
fontSize: 16,
});
export const InlineFlexRow = styled(FlexRow)({
display: 'inline-block',
});
const surfaceDefaultText = 'SELECT ALL SURFACES';
const debugLog = (...args: any[]) => {
if (!isProduction()) {
// eslint-disable-next-line no-console
console.log(...args);
}
};
type ImagesMetaData = {
levels: ImagesListResponse;
events: Array<ImageEventWithId>;
imageDataList: Array<ImageData>;
};
export default class FlipperImagesPlugin extends FlipperPlugin<
PluginState,
BaseAction,
PersistedState
> {
static defaultPersistedState: PersistedState = {
images: [],
events: [],
imagesMap: {},
surfaceList: new Set(),
closeableReferenceLeaks: [],
isLeakTrackingEnabled: false,
nextEventId: 0,
};
static exportPersistedState = (
callClient: (method: string, params?: any) => Promise<any>,
persistedState: PersistedState,
store?: ReduxState,
): Promise<PersistedState> => {
const defaultPromise = Promise.resolve(persistedState);
if (!persistedState) {
persistedState = FlipperImagesPlugin.defaultPersistedState;
}
if (!store) {
return defaultPromise;
}
return Promise.all([
callClient('listImages'),
callClient('getAllImageEventsInfo'),
]).then(async ([responseImages, responseEvents]) => {
const levels: ImagesList = responseImages.levels;
const events: Array<ImageEventWithId> = responseEvents.events;
let pluginData: PersistedState = {
...persistedState,
images: persistedState ? [...persistedState.images, ...levels] : levels,
closeableReferenceLeaks:
(persistedState && persistedState.closeableReferenceLeaks) || [],
};
events.forEach((event: ImageEventWithId, index) => {
if (!event) {
return;
}
const {attribution} = event;
if (
attribution &&
attribution instanceof Array &&
attribution.length > 0
) {
const surface = attribution[0] ? attribution[0].trim() : undefined;
if (surface && surface.length > 0) {
pluginData.surfaceList.add(surface);
}
}
pluginData = {
...pluginData,
events: [{eventId: index, ...event}, ...pluginData.events],
};
});
const idSet: Set<string> = levels.reduce((acc, level: CacheInfo) => {
level.imageIds.forEach(id => {
acc.add(id);
});
return acc;
}, new Set<string>());
const imageDataList: Array<ImageData> = [];
for (const id of idSet) {
try {
const imageData: ImageData = await callClient('getImage', {
imageId: id,
});
imageDataList.push(imageData);
} catch (e) {
console.error(e);
}
}
imageDataList.forEach((data: ImageData) => {
const imagesMap = {...pluginData.imagesMap};
imagesMap[data.imageId] = data;
pluginData.imagesMap = imagesMap;
});
return pluginData;
});
};
static persistedStateReducer = (
persistedState: PersistedState,
method: string,
data: AndroidCloseableReferenceLeakEvent | ImageEvent,
): PersistedState => {
if (method == 'closeable_reference_leak_event') {
const event: AndroidCloseableReferenceLeakEvent = data as AndroidCloseableReferenceLeakEvent;
return {
...persistedState,
closeableReferenceLeaks: persistedState.closeableReferenceLeaks.concat(
event,
),
};
} else if (method == 'events') {
const event: ImageEvent = data as ImageEvent;
debugLog('Received events', event);
const {surfaceList} = persistedState;
const {attribution} = event;
if (attribution instanceof Array && attribution.length > 0) {
const surface = attribution[0] ? attribution[0].trim() : undefined;
if (surface && surface.length > 0) {
surfaceList.add(surface);
}
}
return {
...persistedState,
events: [
{eventId: persistedState.nextEventId, ...event},
...persistedState.events,
],
nextEventId: persistedState.nextEventId + 1,
};
}
return persistedState;
};
static metricsReducer = (
persistedState: PersistedState,
): Promise<MetricType> => {
const {events, imagesMap, closeableReferenceLeaks} = persistedState;
const wastedBytes = (events || []).reduce((acc, event) => {
const {viewport, imageIds} = event;
if (!viewport) {
return acc;
}
return imageIds.reduce((innerAcc, imageID) => {
const imageData: ImageData = imagesMap[imageID];
if (!imageData) {
return innerAcc;
}
const imageWidth: number = imageData.width;
const imageHeight: number = imageData.height;
const viewPortWidth: number = viewport.width;
const viewPortHeight: number = viewport.height;
const viewPortArea = viewPortWidth * viewPortHeight;
const imageArea = imageWidth * imageHeight;
return innerAcc + Math.max(0, imageArea - viewPortArea);
}, acc);
}, 0);
return Promise.resolve({
WASTED_BYTES: wastedBytes,
CLOSEABLE_REFERENCE_LEAKS: (closeableReferenceLeaks || []).length,
});
};
static getActiveNotifications = ({
closeableReferenceLeaks = [],
isLeakTrackingEnabled = false,
}: PersistedState): Array<Notification> =>
closeableReferenceLeaks
.filter(_ => isLeakTrackingEnabled)
.map((event: AndroidCloseableReferenceLeakEvent) => ({
id: event.identityHashCode,
title: `Leaked CloseableReference: ${event.className}`,
message: (
<Fragment>
<InlineFlexRow>
CloseableReference leaked for{' '}
<Text code={true}>{event.className}</Text>
(identity hashcode: {event.identityHashCode}).
</InlineFlexRow>
<InlineFlexRow>
<Text bold={true}>Stacktrace:</Text>
</InlineFlexRow>
<InlineFlexRow>
<Text code={true}>{event.stacktrace || '<unavailable>'}</Text>
</InlineFlexRow>
</Fragment>
),
severity: 'error',
category: 'closeablereference_leak',
}));
state: PluginState = {
selectedSurfaces: new Set([surfaceDefaultText]),
selectedImage: null,
isDebugOverlayEnabled: false,
isAutoRefreshEnabled: false,
images: [],
coldStartFilter: false,
};
imagePool: ImagePool | undefined;
nextEventId: number = 1;
filterImages = (
images: ImagesList,
events: Array<ImageEventWithId>,
surfaces: Set<string>,
coldStart: boolean,
): ImagesList => {
if (!surfaces || (surfaces.has(surfaceDefaultText) && !coldStart)) {
return images;
}
const imageList = images.map((image: CacheInfo) => {
const imageIdList = image.imageIds.filter(imageID => {
const filteredEvents = events.filter((event: ImageEventWithId) => {
const output =
event.attribution &&
event.attribution.length > 0 &&
event.imageIds &&
event.imageIds.includes(imageID);
if (surfaces.has(surfaceDefaultText)) {
return output && coldStart && event.coldStart;
}
return (
(!coldStart || (coldStart && event.coldStart)) &&
output &&
surfaces.has(event.attribution[0])
);
});
return filteredEvents.length > 0;
});
return {...image, imageIds: imageIdList};
});
return imageList;
};
init() {
debugLog('init()');
this.updateCaches('init');
this.client.subscribe(
'debug_overlay_event',
(event: FrescoDebugOverlayEvent) => {
this.setState({isDebugOverlayEnabled: event.enabled});
},
);
this.imagePool = new ImagePool(this.getImage, (images: ImagesMap) =>
this.props.setPersistedState({imagesMap: images}),
);
const images = this.filterImages(
this.props.persistedState.images,
this.props.persistedState.events,
this.state.selectedSurfaces,
this.state.coldStartFilter,
);
this.setState({images});
}
teardown() {
this.imagePool ? this.imagePool.clear() : undefined;
}
updateImagesOnUI = (
images: ImagesList,
surfaces: Set<string>,
coldStart: boolean,
) => {
const filteredImages = this.filterImages(
images,
this.props.persistedState.events,
surfaces,
coldStart,
);
this.setState({
selectedSurfaces: surfaces,
images: filteredImages,
coldStartFilter: coldStart,
});
};
updateCaches = (reason: string) => {
debugLog('Requesting images list (reason=' + reason + ')');
this.client.call('listImages').then((response: ImagesListResponse) => {
response.levels.forEach(data =>
this.imagePool ? this.imagePool.fetchImages(data.imageIds) : undefined,
);
this.props.setPersistedState({images: response.levels});
this.updateImagesOnUI(
this.props.persistedState.images,
this.state.selectedSurfaces,
this.state.coldStartFilter,
);
});
};
onClear = (type: string) => {
this.client.call('clear', {type});
setTimeout(() => this.updateCaches('onClear'), 1000);
};
onTrimMemory = () => {
this.client.call('trimMemory', {});
setTimeout(() => this.updateCaches('onTrimMemory'), 1000);
};
onEnableDebugOverlay = (enabled: boolean) => {
this.client.call('enableDebugOverlay', {enabled});
};
onEnableAutoRefresh = (enabled: boolean) => {
this.setState({isAutoRefreshEnabled: enabled});
if (enabled) {
// Delay the call just enough to allow the state change to complete.
setTimeout(() => this.onAutoRefresh());
}
};
onAutoRefresh = () => {
this.updateCaches('auto-refresh');
if (this.state.isAutoRefreshEnabled) {
setTimeout(() => this.onAutoRefresh(), 1000);
}
};
getImage = (imageId: string) => {
debugLog('<- getImage requested for ' + imageId);
this.client.call('getImage', {imageId}).then((image: ImageData) => {
debugLog('-> getImage ' + imageId + ' returned');
this.imagePool ? this.imagePool._fetchCompleted(image) : undefined;
});
};
onImageSelected = (selectedImage: ImageId) => this.setState({selectedImage});
renderSidebar = () => {
const {selectedImage} = this.state;
if (selectedImage == null) {
return (
<EmptySidebar grow={true}>
<Text align="center">
Select an image to see the events associated with it.
</Text>
</EmptySidebar>
);
}
const maybeImage = this.props.persistedState.imagesMap[selectedImage];
const events = this.props.persistedState.events.filter(e =>
e.imageIds.includes(selectedImage),
);
return <ImagesSidebar image={maybeImage} events={events} />;
};
onSurfaceChange = (surfaces: Set<string>) => {
this.updateImagesOnUI(
this.props.persistedState.images,
surfaces,
this.state.coldStartFilter,
);
};
onColdStartChange = (checked: boolean) => {
this.updateImagesOnUI(
this.props.persistedState.images,
this.state.selectedSurfaces,
checked,
);
};
onTrackLeaks = (checked: boolean) => {
this.props.logger.track('usage', 'fresco:onTrackLeaks', {enabled: checked});
this.props.setPersistedState({
isLeakTrackingEnabled: checked,
});
};
render() {
const options = [...this.props.persistedState.surfaceList].reduce(
(acc, item) => {
return [...acc, item];
},
[surfaceDefaultText],
);
let {selectedSurfaces} = this.state;
if (selectedSurfaces.has(surfaceDefaultText)) {
selectedSurfaces = new Set(options);
}
return (
<React.Fragment>
<ImagesCacheOverview
allSurfacesOption={surfaceDefaultText}
surfaceOptions={new Set(options)}
selectedSurfaces={selectedSurfaces}
onChangeSurface={this.onSurfaceChange}
coldStartFilter={this.state.coldStartFilter}
onColdStartChange={this.onColdStartChange}
images={this.state.images}
onClear={this.onClear}
onTrimMemory={this.onTrimMemory}
onRefresh={() => this.updateCaches('refresh')}
onEnableDebugOverlay={this.onEnableDebugOverlay}
onEnableAutoRefresh={this.onEnableAutoRefresh}
isDebugOverlayEnabled={this.state.isDebugOverlayEnabled}
isAutoRefreshEnabled={this.state.isAutoRefreshEnabled}
onImageSelected={this.onImageSelected}
imagesMap={this.props.persistedState.imagesMap}
events={this.props.persistedState.events}
isLeakTrackingEnabled={
this.props.persistedState.isLeakTrackingEnabled
}
onTrackLeaks={this.onTrackLeaks}
/>
<DetailSidebar>{this.renderSidebar()}</DetailSidebar>
</React.Fragment>
);
}
}

View File

@@ -0,0 +1,12 @@
{
"name": "Fresco",
"version": "1.0.0",
"main": "index.tsx",
"license": "MIT",
"keywords": ["flipper-plugin"],
"title": "Images",
"icon": "profile",
"bugs": {
"email": "oncall+fresco@xmail.facebook.com"
}
}

View File

@@ -0,0 +1,4 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1