Skip to content

Commit

Permalink
Add support for viewing data history after app disconnect (#5509)
Browse files Browse the repository at this point in the history
  • Loading branch information
kenzieschmoll committed Mar 24, 2023
1 parent 886a834 commit 49c8c93
Show file tree
Hide file tree
Showing 20 changed files with 172 additions and 44 deletions.
6 changes: 4 additions & 2 deletions packages/devtools_app/lib/src/framework/framework_core.dart
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ class FrameworkCore {
String url, {
Uri? explicitUri,
required ErrorReporter errorReporter,
bool logException = true,
}) async {
if (serviceManager.hasConnection) {
// TODO(https://github.com/flutter/devtools/issues/1568): why do we call
Expand All @@ -68,8 +69,9 @@ class FrameworkCore {
breakpointManager.initialize();
return true;
} catch (e, st) {
log('$e\n$st', LogLevel.error);

if (logException) {
log('$e\n$st', LogLevel.error);
}
errorReporter('Unable to connect to VM service at $uri: $e', e);
return false;
}
Expand Down
66 changes: 54 additions & 12 deletions packages/devtools_app/lib/src/framework/initializer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ import 'dart:async';

import 'package:devtools_shared/devtools_shared.dart';
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';

import '../shared/analytics/analytics.dart' as ga;
import '../shared/analytics/constants.dart' as gac;
import '../shared/common_widgets.dart';
import '../shared/config_specific/import_export/import_export.dart';
import '../shared/framework_controller.dart';
import '../shared/globals.dart';
import '../shared/primitives/auto_dispose.dart';
Expand All @@ -18,6 +20,8 @@ import '../shared/routing.dart';
import '../shared/theme.dart';
import 'framework_core.dart';

final _log = Logger('initializer');

/// Widget that requires business logic to be loaded before building its
/// [builder].
///
Expand Down Expand Up @@ -78,7 +82,16 @@ class _InitializerState extends State<Initializer>
!connectionState.userInitiatedConnectionState) {
// Try to reconnect (otherwise, will fall back to showing the
// disconnected overlay).
unawaited(_attemptUrlConnection());
unawaited(
_attemptUrlConnection(
logException: false,
errorReporter: (_, __) {
_log.warning(
'Attempted to reconnect to the application, but failed.',
);
},
),
);
}
});

Expand Down Expand Up @@ -112,18 +125,25 @@ class _InitializerState extends State<Initializer>
});
}

Future<void> _attemptUrlConnection() async {
Future<void> _attemptUrlConnection({
ErrorReporter? errorReporter,
bool logException = true,
}) async {
if (widget.url == null) {
_handleNoConnection();
return;
}

errorReporter ??= (String message, Object error) {
notificationService.push('$message, $error');
};

final uri = normalizeVmServiceUri(widget.url!);
final connected = await FrameworkCore.initVmService(
'',
explicitUri: uri,
errorReporter: (message, error) =>
notificationService.push('$message, $error'),
errorReporter: errorReporter,
logException: logException,
);

if (!connected) {
Expand Down Expand Up @@ -155,8 +175,29 @@ class _InitializerState extends State<Initializer>
}

void hideDisconnectedOverlay() {
currentDisconnectedOverlay?.remove();
currentDisconnectedOverlay = null;
setState(() {
currentDisconnectedOverlay?.remove();
currentDisconnectedOverlay = null;
});
}

void _reviewHistory() {
assert(offlineController.offlineDataJson.isNotEmpty);

offlineController.enterOfflineMode(
offlineApp: offlineController.previousConnectedApp!,
);
hideDisconnectedOverlay();
final args = <String, String?>{
'uri': null,
'screen': offlineController
.offlineDataJson[DevToolsExportKeys.activeScreenId.name] as String
};
final routerDelegate = DevToolsRouterDelegate.of(context);
Router.neglect(
context,
() => routerDelegate.navigate(snapshotPageId, args),
);
}

OverlayEntry _createDisconnectedOverlay() {
Expand Down Expand Up @@ -187,11 +228,12 @@ class _InitializerState extends State<Initializer>
style: theme.textTheme.bodyMedium,
),
const Spacer(),
ElevatedButton(
onPressed: hideDisconnectedOverlay,
child: const Text('Review History'),
),
const SizedBox(height: defaultSpacing),
if (offlineController.offlineDataJson.isNotEmpty)
ElevatedButton(
onPressed: _reviewHistory,
child: const Text('Review recent data (offline)'),
),
const Spacer(),
],
),
),
Expand All @@ -202,7 +244,7 @@ class _InitializerState extends State<Initializer>

@override
Widget build(BuildContext context) {
return _checkLoaded()
return _checkLoaded() || offlineController.offlineMode.value
? widget.builder(context)
: Scaffold(
body: currentDisconnectedOverlay != null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ class PerformanceController extends DisposableController

Future<void> _initHelper() async {
initData();
initReviewHistoryOnDisconnectListener();

await _applyToFeatureControllersAsync((c) => c.init());
if (!offlineController.offlineMode.value) {
await serviceManager.onServiceAvailable;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,9 +147,9 @@ class PerformanceData {
flutterFramesKey: frames.map((frame) => frame.json).toList(),
displayRefreshRateKey: displayRefreshRate,
traceEventsKey: traceEvents,
selectedEventKey: selectedEvent?.json ?? {},
cpuProfileKey: cpuProfileData?.toJson ?? {},
rasterStatsKey: rasterStats?.json ?? {},
selectedEventKey: selectedEvent?.json ?? <String, dynamic>{},
cpuProfileKey: cpuProfileData?.toJson ?? <String, dynamic>{},
rasterStatsKey: rasterStats?.json ?? <String, dynamic>{},
rebuildCountModelKey: rebuildCountModel.toJson(),
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ class MethodTableController extends DisposableController
MethodTableController({
required ValueListenable<CpuProfileData?> dataNotifier,
}) {
createMethodTableGraph(dataNotifier.value);
addAutoDisposeListener(dataNotifier, () {
createMethodTableGraph(dataNotifier.value);
});
Expand All @@ -44,12 +45,11 @@ class MethodTableController extends DisposableController
reset();
if (cpuProfileData == null ||
cpuProfileData == CpuProfilerController.baseStateCpuProfileData ||
cpuProfileData == CpuProfilerController.emptyAppStartUpProfile) {
cpuProfileData == CpuProfilerController.emptyAppStartUpProfile ||
!cpuProfileData.processed) {
return;
}

assert(cpuProfileData.processed);

List<CpuStackFrame> profileRoots = cpuProfileData.callTreeRoots;
// For a profile rooted at tags, treat it as if it is not. Otherwise, the
// tags will show up in the method table.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ class ProfilerScreenController extends DisposableController
}

Future<void> _initHelper() async {
initReviewHistoryOnDisconnectListener();
if (!offlineController.offlineMode.value) {
await allowedError(
serviceManager.service!.setProfilePeriod(mediumProfilePeriod),
Expand All @@ -45,7 +46,11 @@ class ProfilerScreenController extends DisposableController

_currentIsolate = serviceManager.isolateManager.selectedIsolate.value;
addAutoDisposeListener(serviceManager.isolateManager.selectedIsolate, () {
switchToIsolate(serviceManager.isolateManager.selectedIsolate.value);
final selectedIsolate =
serviceManager.isolateManager.selectedIsolate.value;
if (selectedIsolate != null) {
switchToIsolate(selectedIsolate);
}
});

addAutoDisposeListener(preferences.vmDeveloperModeEnabled, () async {
Expand Down
8 changes: 8 additions & 0 deletions packages/devtools_app/lib/src/service/service_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,14 @@ class ServiceConnectionManager {
void vmServiceClosed({
ConnectedState connectionState = const ConnectedState(false),
}) {
// Set [offlineController.previousConnectedApp] in case we need it for
// viewing data after disconnect. This must be done before resetting the
// rest of the service manager state.
final previousConnectedApp = connectedApp != null
? OfflineConnectedApp.parse(connectedApp!.toJson())
: null;
offlineController.previousConnectedApp = previousConnectedApp;

_serviceAvailable = Completer();

service = null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,8 @@ class ImportController {
<String, Object>{})
.cast<String, Object>();
offlineController
..enterOfflineMode()
..enterOfflineMode(offlineApp: OfflineConnectedApp.parse(connectedApp))
..offlineDataJson = devToolsSnapshot;
serviceManager.connectedApp = OfflineConnectedApp.parse(connectedApp);
notificationService.push(attemptingToImportMessage(activeScreenId));
_pushSnapshotScreenForImport(activeScreenId);
}
Expand Down Expand Up @@ -147,25 +146,34 @@ abstract class ExportController {
required String fileName,
});

String encode(Map<String, dynamic> contents) {
final activeScreenId = contents[DevToolsExportKeys.activeScreenId.name];
final _contents = {
Map<String, dynamic> generateDataForExport({
required Map<String, dynamic> offlineScreenData,
ConnectedApp? connectedApp,
}) {
final contents = {
DevToolsExportKeys.devToolsSnapshot.name: true,
DevToolsExportKeys.activeScreenId.name: activeScreenId,
DevToolsExportKeys.devToolsVersion.name: version,
DevToolsExportKeys.connectedApp.name:
serviceManager.connectedApp!.toJson(),
connectedApp?.toJson() ?? serviceManager.connectedApp!.toJson(),
...offlineScreenData,
};
final activeScreenId = contents[DevToolsExportKeys.activeScreenId.name];

// This is a workaround to guarantee that DevTools exports are compatible
// with other trace viewers (catapult, perfetto, chrome://tracing), which
// require a top level field named "traceEvents".
if (activeScreenId == ScreenMetaData.performance.id) {
final traceEvents = List<Map<String, dynamic>>.from(
contents[traceEventsFieldName],
contents[activeScreenId][traceEventsFieldName],
);
_contents[traceEventsFieldName] = traceEvents;
contents.remove(traceEventsFieldName);
contents[traceEventsFieldName] = traceEvents;
contents[activeScreenId].remove(traceEventsFieldName);
}
return jsonEncode(_contents..addAll({activeScreenId: contents}));
return contents;
}

String encode(Map<String, dynamic> offlineScreenData) {
final data = generateDataForExport(offlineScreenData: offlineScreenData);
return jsonEncode(data);
}
}
2 changes: 1 addition & 1 deletion packages/devtools_app/lib/src/shared/connected_app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ class OfflineConnectedApp extends ConnectedApp {
this.operatingSystem = ConnectedApp._unknownOS,
});

factory OfflineConnectedApp.parse(Map<String, Object>? json) {
factory OfflineConnectedApp.parse(Map<String, Object?>? json) {
if (json == null) return OfflineConnectedApp();
return OfflineConnectedApp(
isFlutterAppNow: json[ConnectedApp.isFlutterAppKey] as bool?,
Expand Down
32 changes: 30 additions & 2 deletions packages/devtools_app/lib/src/shared/offline_mode.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import 'config_specific/import_export/import_export.dart';
import 'connected_app.dart';
import 'globals.dart';
import 'primitives/auto_dispose.dart';
import 'routing.dart';

class OfflineModeController {
bool get isOffline => _offlineMode.value;
Expand All @@ -30,8 +31,9 @@ class OfflineModeController {
offlineDataJson[screenId] != null;
}

void enterOfflineMode() {
void enterOfflineMode({required ConnectedApp offlineApp}) {
previousConnectedApp = serviceManager.connectedApp;
serviceManager.connectedApp = offlineApp;
_offlineMode.value = true;
}

Expand Down Expand Up @@ -71,6 +73,32 @@ mixin OfflineScreenControllerMixin<T> on AutoDisposeControllerMixin {
final encodedData = _exportController.encode(screenDataForExport().json);
_exportController.downloadFile(encodedData);
}

/// Adds a listener that will prepare the screen's current data for offline
/// viewing after an app disconnect.
///
/// This is in preparation for the user clicking the 'Review History' button
/// from the disconnect screen.
void initReviewHistoryOnDisconnectListener() {
addAutoDisposeListener(serviceManager.connectedState, () {
final connectionState = serviceManager.connectedState.value;
if (!connectionState.connected &&
!connectionState.userInitiatedConnectionState) {
final currentScreenData = screenDataForExport();
// Only store data for the current page. We can change this in the
// future if we support offline imports for more than once screen at a
// time.
if (DevToolsRouterDelegate.currentPage == currentScreenData.screenId) {
final previouslyConnectedApp = offlineController.previousConnectedApp;
final offlineData = _exportController.generateDataForExport(
offlineScreenData: currentScreenData.json,
connectedApp: previouslyConnectedApp,
);
offlineController.offlineDataJson = offlineData;
}
}
});
}
}

class OfflineScreenData {
Expand All @@ -82,6 +110,6 @@ class OfflineScreenData {

Map<String, Object?> get json => {
DevToolsExportKeys.activeScreenId.name: screenId,
...data,
screenId: data,
};
}
4 changes: 4 additions & 0 deletions packages/devtools_app/lib/src/shared/routing.dart
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,9 @@ class DevToolsRouterDelegate extends RouterDelegate<DevToolsRouteConfiguration>
@override
final GlobalKey<NavigatorState> navigatorKey;

static String get currentPage => _currentPage;
static late String _currentPage;

final Page Function(
BuildContext,
String?,
Expand Down Expand Up @@ -222,6 +225,7 @@ class DevToolsRouterDelegate extends RouterDelegate<DevToolsRouteConfiguration>

/// Replaces the navigation stack with a new route.
void _replaceStack(DevToolsRouteConfiguration configuration) {
_currentPage = configuration.page;
routes
..clear()
..add(configuration);
Expand Down
3 changes: 3 additions & 0 deletions packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ Dart & Flutter DevTools - A Suite of Performance Tools for Dart and Flutter
* Added the new verbose logging feature for helping us debug user issues. [#5404](https://github.com/flutter/devtools/pull/5404)
![verbose logging](images/verbose-logging.png "verbose_logging")
* Fix a bug where some asynchronous errors were not being reported. [#5456](https://github.com/flutter/devtools/pull/5456)
* Added support for viewing data after an app disconnects for screens that
support offline viewing (currently only the Performance and CPU proiler pages).
[#5509](https://github.com/flutter/devtools/pull/5509)

## Inspector updates
TODO: Remove this section if there are not any general updates.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,9 @@ void main() {

testWidgetsWithWindowSize('builds for offline mode', windowSize,
(WidgetTester tester) async {
offlineController.enterOfflineMode();
offlineController.enterOfflineMode(
offlineApp: serviceManager.connectedApp!,
);
await _pumpControls(tester);
expect(find.byType(ExitOfflineButton), findsOneWidget);
expect(find.byType(VisibilityButton), findsOneWidget);
Expand Down

0 comments on commit 49c8c93

Please sign in to comment.