blob: b57557ce9ee0469ed899859a2d77abd412220c7e [file] [log] [blame]
// Copyright 2013 The Flutter 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 'package:meta/meta.dart';
import 'package:ui/ui.dart' as ui;
import '../dom.dart';
import '../platform_dispatcher.dart';
import '../services/message_codec.dart';
import '../services/message_codecs.dart';
import 'url_strategy.dart';
/// Infers the history mode from the existing browser history state, then
/// creates the appropriate instance of [BrowserHistory] for it.
///
/// If it can't infer, it creates a [MultiEntriesBrowserHistory] by default.
BrowserHistory createHistoryForExistingState(UrlStrategy? urlStrategy) {
if (urlStrategy != null) {
final Object? state = urlStrategy.getState();
if (SingleEntryBrowserHistory._isOriginEntry(state) || SingleEntryBrowserHistory._isFlutterEntry(state)) {
return SingleEntryBrowserHistory(urlStrategy: urlStrategy);
}
}
return MultiEntriesBrowserHistory(urlStrategy: urlStrategy);
}
/// An abstract class that provides the API for [EngineWindow] to delegate its
/// navigating events.
///
/// Subclasses will have access to [BrowserHistory.locationStrategy] to
/// interact with the html browser history and should come up with their own
/// ways to manage the states in the browser history.
///
/// There should only be one global instance among all subclasses.
///
/// See also:
///
/// * [SingleEntryBrowserHistory]: which creates a single fake browser history
/// entry and delegates all browser navigating events to the flutter
/// framework.
/// * [MultiEntriesBrowserHistory]: which creates a set of states that records
/// the navigating events happened in the framework.
abstract class BrowserHistory {
late ui.VoidCallback _unsubscribe;
/// The strategy to interact with html browser history.
UrlStrategy? get urlStrategy;
bool _isTornDown = false;
bool _isDisposed = false;
void _setupStrategy(UrlStrategy strategy) {
_unsubscribe = strategy.addPopStateListener(onPopState as DomEventListener);
}
/// Release any resources held by this [BrowserHistory] instance.
///
/// This method has no effect on the browser history entries. Use [tearDown]
/// instead to revert this instance's modifications to browser history
/// entries.
@mustCallSuper
void dispose() {
if (_isDisposed || urlStrategy == null) {
return;
}
_isDisposed = true;
_unsubscribe();
}
/// Exit this application and return to the previous page.
Future<void> exit() async {
if (urlStrategy != null) {
await tearDown();
// Now the history should be in the original state, back one more time to
// exit the application.
await urlStrategy!.go(-1);
}
}
/// This method does the same thing as the browser back button.
Future<void> back() async {
return urlStrategy?.go(-1);
}
/// The path of the current location of the user's browser.
String get currentPath => urlStrategy?.getPath() ?? '/';
/// The state of the current location of the user's browser.
Object? get currentState => urlStrategy?.getState();
/// Update the url with the given `routeName` and `state`.
///
/// If `replace` is false, the caller wants to push a new `routeName` and
/// `state` on top of the existing ones; otherwise, the caller wants to replace
/// the current `routeName` and `state` with the new ones.
void setRouteName(String? routeName, {Object? state, bool replace = false});
/// A callback method to handle browser backward or forward buttons.
///
/// Subclasses should send appropriate system messages to update the flutter
/// applications accordingly.
void onPopState(covariant DomPopStateEvent event);
/// Restore any modifications to the html browser history during the lifetime
/// of this class.
Future<void> tearDown();
}
/// A browser history class that creates a set of browser history entries to
/// support browser backward and forward button natively.
///
/// This class pushes a browser history entry every time the framework reports
/// a route change and sends a `pushRouteInformation` method call to the
/// framework when the browser jumps to a specific browser history entry.
///
/// The web engine uses this class to manage its browser history when the
/// framework uses a Router for routing.
///
/// See also:
///
/// * [SingleEntryBrowserHistory], which is used when the framework does not use
/// a Router for routing.
class MultiEntriesBrowserHistory extends BrowserHistory {
MultiEntriesBrowserHistory({required this.urlStrategy}) {
final UrlStrategy? strategy = urlStrategy;
if (strategy == null) {
return;
}
_setupStrategy(strategy);
if (!_hasSerialCount(currentState)) {
strategy.replaceState(
_tagWithSerialCount(currentState, 0), 'flutter', currentPath);
}
// If we restore from a page refresh, the _currentSerialCount may not be 0.
_lastSeenSerialCount = _currentSerialCount;
}
@override
final UrlStrategy? urlStrategy;
late int _lastSeenSerialCount;
int get _currentSerialCount {
if (_hasSerialCount(currentState)) {
final Map<dynamic, dynamic> stateMap =
currentState! as Map<dynamic, dynamic>;
return (stateMap['serialCount'] as double).toInt();
}
return 0;
}
Object _tagWithSerialCount(Object? originialState, int count) {
return <dynamic, dynamic>{
'serialCount': count.toDouble(),
'state': originialState,
};
}
bool _hasSerialCount(Object? state) {
return state is Map && state['serialCount'] != null;
}
@override
void setRouteName(String? routeName, {Object? state, bool replace = false}) {
if (urlStrategy != null) {
assert(routeName != null);
if (replace) {
urlStrategy!.replaceState(
_tagWithSerialCount(state, _lastSeenSerialCount),
'flutter',
routeName!,
);
} else {
_lastSeenSerialCount += 1;
urlStrategy!.pushState(
_tagWithSerialCount(state, _lastSeenSerialCount),
'flutter',
routeName!,
);
}
}
}
@override
void onPopState(covariant DomPopStateEvent event) {
assert(urlStrategy != null);
// May be a result of direct url access while the flutter application is
// already running.
if (!_hasSerialCount(event.state)) {
// In this case we assume this will be the next history entry from the
// last seen entry.
urlStrategy!.replaceState(
_tagWithSerialCount(event.state, _lastSeenSerialCount + 1),
'flutter',
currentPath);
}
_lastSeenSerialCount = _currentSerialCount;
EnginePlatformDispatcher.instance.invokeOnPlatformMessage(
'flutter/navigation',
const JSONMethodCodec().encodeMethodCall(
MethodCall('pushRouteInformation', <dynamic, dynamic>{
'location': currentPath,
'state': event.state?['state'],
})),
(_) {},
);
}
@override
Future<void> tearDown() async {
dispose();
if (_isTornDown || urlStrategy == null) {
return;
}
_isTornDown = true;
// Restores the html browser history.
assert(_hasSerialCount(currentState));
final int backCount = _currentSerialCount;
if (backCount > 0) {
await urlStrategy!.go(-backCount.toDouble());
}
// Unwrap state.
assert(_hasSerialCount(currentState) && _currentSerialCount == 0);
final Map<dynamic, dynamic> stateMap =
currentState! as Map<dynamic, dynamic>;
urlStrategy!.replaceState(
stateMap['state'],
'flutter',
currentPath,
);
}
}
/// The browser history class is responsible for integrating Flutter Web apps
/// with the browser history so that the back button works as expected.
///
/// It does that by always keeping a single entry (conventionally called the
/// "flutter" entry) at the top of the browser history. That way, the browser's
/// back button always triggers a `popstate` event and never closes the app (we
/// close the app programmatically by calling [SystemNavigator.pop] when there
/// are no more app routes to be popped).
///
/// The web engine uses this class when the framework does not use Router for
/// routing, and it does not support browser forward button.
///
/// See also:
///
/// * [MultiEntriesBrowserHistory], which is used when the framework uses a
/// Router for routing.
class SingleEntryBrowserHistory extends BrowserHistory {
SingleEntryBrowserHistory({required this.urlStrategy}) {
final UrlStrategy? strategy = urlStrategy;
if (strategy == null) {
return;
}
_setupStrategy(strategy);
final String path = currentPath;
if (!_isFlutterEntry(domWindow.history.state)) {
// An entry may not have come from Flutter, for example, when the user
// refreshes the page. They land directly on the "flutter" entry, so
// there's no need to set up the "origin" and "flutter" entries, we can
// safely assume they are already set up.
_setupOriginEntry(strategy);
_setupFlutterEntry(strategy, path: path);
}
}
@override
final UrlStrategy? urlStrategy;
static const MethodCall _popRouteMethodCall = MethodCall('popRoute');
static const String _kFlutterTag = 'flutter';
static const String _kOriginTag = 'origin';
Map<String, dynamic> _wrapOriginState(Object? state) {
return <String, dynamic>{_kOriginTag: true, 'state': state};
}
Object? _unwrapOriginState(Object? state) {
assert(_isOriginEntry(state));
final Map<dynamic, dynamic> originState = state! as Map<dynamic, dynamic>;
return originState['state'];
}
final Map<String, bool> _flutterState = <String, bool>{_kFlutterTag: true};
/// The origin entry is the history entry that the Flutter app landed on. It's
/// created by the browser when the user navigates to the url of the app.
static bool _isOriginEntry(Object? state) {
return state is Map && state[_kOriginTag] == true;
}
/// The flutter entry is a history entry that we maintain on top of the origin
/// entry. It allows us to catch popstate events when the user hits the back
/// button.
static bool _isFlutterEntry(Object? state) {
return state is Map && state[_kFlutterTag] == true;
}
@override
void setRouteName(String? routeName, {Object? state, bool replace = false}) {
if (urlStrategy != null) {
_setupFlutterEntry(urlStrategy!, replace: true, path: routeName);
}
}
String? _userProvidedRouteName;
@override
void onPopState(covariant DomPopStateEvent event) {
if (_isOriginEntry(event.state)) {
_setupFlutterEntry(urlStrategy!);
// 2. Send a 'popRoute' platform message so the app can handle it accordingly.
EnginePlatformDispatcher.instance.invokeOnPlatformMessage(
'flutter/navigation',
const JSONMethodCodec().encodeMethodCall(_popRouteMethodCall),
(_) {},
);
} else if (_isFlutterEntry(event.state)) {
// We get into this scenario when the user changes the url manually. It
// causes a new entry to be pushed on top of our "flutter" one. When this
// happens it first goes to the "else" section below where we capture the
// path into `_userProvidedRouteName` then trigger a history back which
// brings us here.
assert(_userProvidedRouteName != null);
final String newRouteName = _userProvidedRouteName!;
_userProvidedRouteName = null;
// Send a 'pushRoute' platform message so the app handles it accordingly.
EnginePlatformDispatcher.instance.invokeOnPlatformMessage(
'flutter/navigation',
const JSONMethodCodec().encodeMethodCall(
MethodCall('pushRoute', newRouteName),
),
(_) {},
);
} else {
// The user has pushed a new entry on top of our flutter entry. This could
// happen when the user modifies the hash part of the url directly, for
// example.
// 1. We first capture the user's desired path.
_userProvidedRouteName = currentPath;
// 2. Then we remove the new entry.
// This will take us back to our "flutter" entry and it causes a new
// popstate event that will be handled in the "else if" section above.
urlStrategy!.go(-1);
}
}
/// This method should be called when the Origin Entry is active. It just
/// replaces the state of the entry so that we can recognize it later using
/// [_isOriginEntry] inside [_popStateListener].
void _setupOriginEntry(UrlStrategy strategy) {
assert(strategy != null);
strategy.replaceState(_wrapOriginState(currentState), 'origin', '');
}
/// This method is used manipulate the Flutter Entry which is always the
/// active entry while the Flutter app is running.
void _setupFlutterEntry(
UrlStrategy strategy, {
bool replace = false,
String? path,
}) {
assert(strategy != null);
path ??= currentPath;
if (replace) {
strategy.replaceState(_flutterState, 'flutter', path);
} else {
strategy.pushState(_flutterState, 'flutter', path);
}
}
@override
Future<void> tearDown() async {
dispose();
if (_isTornDown || urlStrategy == null) {
return;
}
_isTornDown = true;
// We need to remove the flutter entry that we pushed in setup.
await urlStrategy!.go(-1);
// Restores original state.
urlStrategy!
.replaceState(_unwrapOriginState(currentState), 'flutter', currentPath);
}
}