blob: c1a7f1a4f54555b1f9a087ddc4d497287cc34c0a [file] [log] [blame]
// Copyright 2020 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:collection';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'globals.dart';
import 'primitives/auto_dispose.dart';
import 'primitives/utils.dart';
const memoryAnalysisScreenId = 'memoryanalysis';
const homeScreenId = '';
const snapshotScreenId = 'snapshot';
/// Represents a Page/route for a DevTools screen.
class DevToolsRouteConfiguration {
DevToolsRouteConfiguration(this.page, this.args, this.state);
final String page;
final Map<String, String?> args;
final DevToolsNavigationState? state;
}
/// Converts between structured [DevToolsRouteConfiguration] (our internal data
/// for pages/routing) and [RouteInformation] (generic data that can be persisted
/// in the address bar/state objects).
class DevToolsRouteInformationParser
extends RouteInformationParser<DevToolsRouteConfiguration> {
DevToolsRouteInformationParser();
@visibleForTesting
DevToolsRouteInformationParser.test(this._forceVmServiceUri);
/// The value for the 'uri' query parameter in a DevTools uri.
///
/// This is to be used in a testing environment only and can be set via the
/// [DevToolsRouteInformationParser.test] constructor.
String? _forceVmServiceUri;
@override
Future<DevToolsRouteConfiguration> parseRouteInformation(
RouteInformation routeInformation,
) {
var uri = routeInformation.uri;
if (_forceVmServiceUri != null) {
final newQueryParams = Map<String, dynamic>.from(uri.queryParameters);
newQueryParams['uri'] = _forceVmServiceUri;
uri = uri.copyWith(queryParameters: newQueryParams);
}
// If the uri has been modified and we do not have a vm service uri as a
// query parameter, ensure we manually disconnect from any previously
// connected applications.
if (uri.queryParameters['uri'] == null) {
serviceManager.manuallyDisconnect();
}
// routeInformation.path comes from the address bar and (when not empty) is
// prefixed with a leading slash. Internally we use "page IDs" that do not
// start with slashes but match the screenId for each screen.
final path = uri.path.isNotEmpty ? uri.path.substring(1) : '';
final configuration = DevToolsRouteConfiguration(
path,
uri.queryParameters,
_navigationStateFromRouteInformation(routeInformation),
);
return SynchronousFuture<DevToolsRouteConfiguration>(configuration);
}
@override
RouteInformation restoreRouteInformation(
DevToolsRouteConfiguration configuration,
) {
// Add a leading slash to convert the page ID to a URL path (this is
// the opposite of what's done in [parseRouteInformation]).
final path = '/${configuration.page}';
// Create a new map in case the one we were given was unmodifiable.
final params = {...configuration.args};
params.removeWhere((key, value) => value == null);
return RouteInformation(
uri: Uri(path: path, queryParameters: params),
state: configuration.state,
);
}
DevToolsNavigationState? _navigationStateFromRouteInformation(
RouteInformation routeInformation,
) {
final routeState = routeInformation.state;
if (routeState == null) return null;
try {
return DevToolsNavigationState._(
(routeState as Map).cast<String, String?>(),
);
} catch (_) {
return null;
}
}
}
class DevToolsRouterDelegate extends RouterDelegate<DevToolsRouteConfiguration>
with
ChangeNotifier,
PopNavigatorRouterDelegateMixin<DevToolsRouteConfiguration> {
DevToolsRouterDelegate(this._getPage, [GlobalKey<NavigatorState>? key])
: navigatorKey = key ?? GlobalKey<NavigatorState>();
static DevToolsRouterDelegate of(BuildContext context) =>
Router.of(context).routerDelegate as DevToolsRouterDelegate;
@override
final GlobalKey<NavigatorState> navigatorKey;
static String get currentPage => _currentPage;
static late String _currentPage;
final Page Function(
BuildContext,
String?,
Map<String, String?>,
DevToolsNavigationState?,
) _getPage;
/// A list of any routes/pages on the stack.
///
/// This will usually only contain a single item (it's the visible stack,
/// not the history).
final routes = ListQueue<DevToolsRouteConfiguration>();
@override
DevToolsRouteConfiguration? get currentConfiguration =>
routes.isEmpty ? null : routes.last;
@override
Widget build(BuildContext context) {
final routeConfig = currentConfiguration;
final page = routeConfig?.page;
final args = routeConfig?.args ?? {};
final state = routeConfig?.state;
return Navigator(
key: navigatorKey,
pages: [_getPage(context, page, args, state)],
onPopPage: (_, __) {
if (routes.length <= 1) {
return false;
}
routes.removeLast();
notifyListeners();
return true;
},
);
}
/// Navigates to a new page, optionally updating arguments and state.
///
/// If page, args, and state would be the same, does nothing.
/// Existing arguments (for example &uri=) will be preserved unless
/// overwritten by [argUpdates].
void navigateIfNotCurrent(
String page, [
Map<String, String?>? argUpdates,
DevToolsNavigationState? stateUpdates,
]) {
final pageChanged = page != currentConfiguration!.page;
final argsChanged = _changesArgs(argUpdates);
final stateChanged = _changesState(stateUpdates);
if (!pageChanged && !argsChanged && !stateChanged) {
return;
}
navigate(page, argUpdates, stateUpdates);
}
/// Navigates to a new page, optionally updating arguments and state.
///
/// Existing arguments (for example &uri=) will be preserved unless
/// overwritten by [argUpdates].
void navigate(
String page, [
Map<String, String?>? argUpdates,
DevToolsNavigationState? state,
]) {
final newArgs = {...currentConfiguration?.args ?? {}, ...?argUpdates};
// Ensure we disconnect from any previously connected applications if we do
// not have a vm service uri as a query parameter, unless we are loading an
// offline file.
if (page != snapshotScreenId && newArgs['uri'] == null) {
serviceManager.manuallyDisconnect();
}
_replaceStack(
DevToolsRouteConfiguration(page, newArgs, state),
);
notifyListeners();
}
void navigateHome({
bool clearUriParam = false,
required bool clearScreenParam,
}) {
navigate(
homeScreenId,
{
if (clearUriParam) 'uri': null,
if (clearScreenParam) 'screen': null,
},
);
}
/// Replaces the navigation stack with a new route.
void _replaceStack(DevToolsRouteConfiguration configuration) {
_currentPage = configuration.page;
routes
..clear()
..add(configuration);
}
@override
Future<void> setNewRoutePath(DevToolsRouteConfiguration configuration) {
_replaceStack(configuration);
notifyListeners();
return SynchronousFuture<void>(null);
}
/// Updates arguments for the current page.
///
/// Existing arguments (for example &uri=) will be preserved unless
/// overwritten by [argUpdates].
void updateArgsIfChanged(Map<String, String> argUpdates) {
final argsChanged = _changesArgs(argUpdates);
if (!argsChanged) {
return;
}
final currentConfig = currentConfiguration!;
final currentPage = currentConfig.page;
final newArgs = {...currentConfig.args, ...argUpdates};
_replaceStack(
DevToolsRouteConfiguration(
currentPage,
newArgs,
currentConfig.state,
),
);
notifyListeners();
}
Future<void> replaceState(DevToolsNavigationState state) async {
final currentConfig = currentConfiguration!;
_replaceStack(
DevToolsRouteConfiguration(
currentConfig.page,
currentConfig.args,
state,
),
);
final path = '/${currentConfig.page}';
// Create a new map in case the one we were given was unmodifiable.
final params = Map.of(currentConfig.args);
params.removeWhere((key, value) => value == null);
await SystemNavigator.routeInformationUpdated(
uri: Uri(path: path, queryParameters: params),
state: state,
replace: true,
);
}
/// Updates state for the current page.
///
/// Existing state will be preserved unless overwritten by [stateUpdate].
void updateStateIfChanged(DevToolsNavigationState stateUpdate) {
final stateChanged = _changesState(stateUpdate);
if (!stateChanged) {
return;
}
final currentConfig = currentConfiguration!;
_replaceStack(
DevToolsRouteConfiguration(
currentConfig.page,
currentConfig.args,
currentConfig.state?.merge(stateUpdate) ?? stateUpdate,
),
);
// Add the new state to the browser history.
notifyListeners();
}
/// Checks whether applying [changes] over the current route's args will result
/// in any changes.
bool _changesArgs(Map<String, String?>? changes) {
final currentConfig = currentConfiguration!;
return !mapEquals(
{...currentConfig.args, ...?changes},
{...currentConfig.args},
);
}
/// Checks whether applying [changes] over the current route's state will result
/// in any changes.
bool _changesState(DevToolsNavigationState? changes) {
final currentState = currentConfiguration!.state;
if (currentState == null) {
return changes != null;
}
return currentState.hasChanges(changes);
}
}
/// Encapsulates state associated with a [Router] navigation event.
class DevToolsNavigationState {
DevToolsNavigationState({
required this.kind,
required Map<String, String?> state,
}) : _state = {
_kKind: kind,
...state,
};
factory DevToolsNavigationState.fromJson(Map<String, dynamic> json) =>
DevToolsNavigationState._(json.cast<String, String?>());
DevToolsNavigationState._(this._state) : kind = _state[_kKind]!;
static const _kKind = '_kind';
final String kind;
UnmodifiableMapView<String, String?> get state => UnmodifiableMapView(_state);
final Map<String, String?> _state;
bool hasChanges(DevToolsNavigationState? other) {
return !mapEquals(
{...state, ...?other?.state},
state,
);
}
/// Creates a new [DevToolsNavigationState] by merging this instance with
/// [other].
///
/// State contained in [other] will take precedence over state contained in
/// this instance (e.g., if both instances have state with the same key, the
/// state in [other] will be used).
DevToolsNavigationState merge(DevToolsNavigationState other) {
final newState = <String, String?>{
..._state,
...other._state,
};
return DevToolsNavigationState(kind: kind, state: newState);
}
@override
String toString() => _state.toString();
Map<String, dynamic> toJson() => _state;
}
/// Mixin that gives controllers the ability to respond to changes in router
/// navigation state.
mixin RouteStateHandlerMixin on DisposableController {
DevToolsRouterDelegate? _delegate;
@override
void dispose() {
super.dispose();
_delegate?.removeListener(_onRouteStateUpdate);
}
void subscribeToRouterEvents(DevToolsRouterDelegate delegate) {
final oldDelegate = _delegate;
if (oldDelegate != null) {
oldDelegate.removeListener(_onRouteStateUpdate);
}
delegate.addListener(_onRouteStateUpdate);
_delegate = delegate;
}
void _onRouteStateUpdate() {
final state = _delegate?.currentConfiguration?.state;
if (state == null) return;
onRouteStateUpdate(state);
}
/// Perform operations based on changes in navigation state.
///
/// This method is only invoked if [subscribeToRouterEvents] has been called on
/// this instance with a valid [DevToolsRouterDelegate].
void onRouteStateUpdate(DevToolsNavigationState state);
}