/** * 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; images: ImagesList; events: Array; imagesMap: ImagesMap; closeableReferenceLeaks: Array; isLeakTrackingEnabled: boolean; nextEventId: number; }; type PluginState = { selectedSurfaces: Set; 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; imageDataList: Array; }; 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, persistedState: PersistedState, store?: ReduxState, ): Promise => { 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 = 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 = levels.reduce((acc, level: CacheInfo) => { level.imageIds.forEach(id => { acc.add(id); }); return acc; }, new Set()); const imageDataList: Array = []; 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 => { 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 => closeableReferenceLeaks .filter(_ => isLeakTrackingEnabled) .map((event: AndroidCloseableReferenceLeakEvent) => ({ id: event.identityHashCode, title: `Leaked CloseableReference: ${event.className}`, message: ( CloseableReference leaked for{' '} {event.className} (identity hashcode: {event.identityHashCode}). Stacktrace: {event.stacktrace || ''} ), 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, surfaces: Set, 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, 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 ( Select an image to see the events associated with it. ); } const maybeImage = this.props.persistedState.imagesMap[selectedImage]; const events = this.props.persistedState.events.filter(e => e.imageIds.includes(selectedImage), ); return ; }; onSurfaceChange = (surfaces: Set) => { 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 ( 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} /> {this.renderSidebar()} ); } }