From 175e5c9aca693ebfc3cb8da80ef26e72d2d10506 Mon Sep 17 00:00:00 2001 From: Michael Goderbauer Date: Tue, 28 Jul 2020 10:16:36 -0700 Subject: [PATCH] Restoration Framework (#60375) * state restoration * added example * typos and analyzer * whitespace * more typos * remove unnecessary import * whitespace * fix sample code * tests for restorationmanager and restorationid * ++ * typo * tests for bucket, part1 * rename tests * more tests * finished tests for service layer * remove wrong todo * ++ * review comments * tests for Unmanaged and regular scope * RootRestorationScope tests * typo * whitespace * testing framework * tests for properties * last set of tests * analyzer * typo * dan review * whitespace * ++ * refactor finalizers * ++ * ++ * dispose guard * ++ * ++ * dan review * add manager assert * ++ * analyzer * greg review * fix typo * Ian & John review * ian review * RestorationID -> String * revert comment * Make primitives non-nullable in prep for NNBD --- packages/flutter/lib/services.dart | 1 + .../flutter/lib/src/services/binding.dart | 23 + .../flutter/lib/src/services/restoration.dart | 963 +++++++++++++++ .../lib/src/services/system_channels.dart | 31 + .../flutter/lib/src/widgets/restoration.dart | 1053 +++++++++++++++++ .../src/widgets/restoration_properties.dart | 267 +++++ packages/flutter/lib/widgets.dart | 2 + .../flutter/test/services/restoration.dart | 67 ++ .../services/restoration_bucket_test.dart | 629 ++++++++++ .../test/services/restoration_test.dart | 270 +++++ .../widgets/restorable_property_test.dart | 377 ++++++ .../flutter/test/widgets/restoration.dart | 34 + .../test/widgets/restoration_mixin_test.dart | 833 +++++++++++++ .../test/widgets/restoration_scope_test.dart | 387 ++++++ .../widgets/root_restoration_scope_test.dart | 400 +++++++ packages/flutter_test/lib/flutter_test.dart | 1 + packages/flutter_test/lib/src/binding.dart | 16 + .../flutter_test/lib/src/restoration.dart | 85 ++ .../flutter_test/lib/src/widget_tester.dart | 49 + .../flutter_test/test/restoration_test.dart | 111 ++ 20 files changed, 5599 insertions(+) create mode 100644 packages/flutter/lib/src/services/restoration.dart create mode 100644 packages/flutter/lib/src/widgets/restoration.dart create mode 100644 packages/flutter/lib/src/widgets/restoration_properties.dart create mode 100644 packages/flutter/test/services/restoration.dart create mode 100644 packages/flutter/test/services/restoration_bucket_test.dart create mode 100644 packages/flutter/test/services/restoration_test.dart create mode 100644 packages/flutter/test/widgets/restorable_property_test.dart create mode 100644 packages/flutter/test/widgets/restoration.dart create mode 100644 packages/flutter/test/widgets/restoration_mixin_test.dart create mode 100644 packages/flutter/test/widgets/restoration_scope_test.dart create mode 100644 packages/flutter/test/widgets/root_restoration_scope_test.dart create mode 100644 packages/flutter_test/lib/src/restoration.dart create mode 100644 packages/flutter_test/test/restoration_test.dart diff --git a/packages/flutter/lib/services.dart b/packages/flutter/lib/services.dart index ff5422e02af..b794ac309a9 100644 --- a/packages/flutter/lib/services.dart +++ b/packages/flutter/lib/services.dart @@ -33,6 +33,7 @@ export 'src/services/raw_keyboard_linux.dart'; export 'src/services/raw_keyboard_macos.dart'; export 'src/services/raw_keyboard_web.dart'; export 'src/services/raw_keyboard_windows.dart'; +export 'src/services/restoration.dart'; export 'src/services/system_channels.dart'; export 'src/services/system_chrome.dart'; export 'src/services/system_navigator.dart'; diff --git a/packages/flutter/lib/src/services/binding.dart b/packages/flutter/lib/src/services/binding.dart index 45fe36ec16c..0659d8622d0 100644 --- a/packages/flutter/lib/src/services/binding.dart +++ b/packages/flutter/lib/src/services/binding.dart @@ -13,6 +13,7 @@ import 'package:flutter/scheduler.dart'; import 'asset_bundle.dart'; import 'binary_messenger.dart'; +import 'restoration.dart'; import 'system_channels.dart'; /// Listens for platform messages and directs them to the [defaultBinaryMessenger]. @@ -27,6 +28,7 @@ mixin ServicesBinding on BindingBase, SchedulerBinding { super.initInstances(); _instance = this; _defaultBinaryMessenger = createBinaryMessenger(); + _restorationManager = createRestorationManager(); window.onPlatformMessage = defaultBinaryMessenger.handlePlatformMessage; initLicenses(); SystemChannels.system.setMessageHandler(handleSystemMessage); @@ -204,6 +206,27 @@ mixin ServicesBinding on BindingBase, SchedulerBinding { } return null; } + + /// The [RestorationManager] synchronizes the restoration data between + /// engine and framework. + /// + /// See the docs for [RestorationManager] for a discussion of restoration + /// state and how it is organized in Flutter. + /// + /// To use a different [RestorationManager] subclasses can override + /// [createRestorationManager], which is called to create the instance + /// returned by this getter. + RestorationManager get restorationManager => _restorationManager; + RestorationManager _restorationManager; + + /// Creates the [RestorationManager] instance available via + /// [restorationManager]. + /// + /// Can be overriden in subclasses to create a different [RestorationManager]. + @protected + RestorationManager createRestorationManager() { + return RestorationManager(); + } } /// The default implementation of [BinaryMessenger]. diff --git a/packages/flutter/lib/src/services/restoration.dart b/packages/flutter/lib/src/services/restoration.dart new file mode 100644 index 00000000000..e6119c7b522 --- /dev/null +++ b/packages/flutter/lib/src/services/restoration.dart @@ -0,0 +1,963 @@ +// Copyright 2014 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. + +// @dart = 2.8 + +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/scheduler.dart'; + +import 'message_codec.dart'; +import 'message_codecs.dart'; +import 'system_channels.dart'; + +typedef _BucketVisitor = void Function(RestorationBucket bucket); + +/// Manages the restoration data in the framework and synchronizes it with the +/// engine. +/// +/// Restoration data can be serialized out and - at a later point in time - be +/// used to restore the application to the previous state described by the +/// serialized data. Mobile operating systems use the concept of state +/// restoration to provide the illusion that apps continue to run in the +/// background forever: after an app has been backgrounded, the user can always +/// return to it and find it in the same state. In practice, the operating +/// system may, however, terminate the app to free resources for other apps +/// running in the foreground. Before that happens, the app gets a chance to +/// serialize out its restoration data. When the user navigates back to the +/// backgrounded app, it is restarted and the serialized restoration data is +/// provided to it again. Ideally, the app will use that data to restore itself +/// to the same state it was in when the user backgrounded the app. +/// +/// In Flutter, restoration data is organized in a tree of [RestorationBucket]s +/// which is rooted in the [rootBucket]. All information that the application +/// needs to restore its current state must be stored in a bucket in this +/// hierarchy. To store data in the hierarchy, entities (e.g. [Widget]s) must +/// claim ownership of a child bucket from a parent bucket (which may be the +/// [rootBucket] provided by this [RestorationManager]). The owner of a bucket +/// may store arbitrary values in the bucket as long as they can be serialized +/// with the [StandardMessageCodec]. The values are stored in the bucket under a +/// given restoration ID as key. A restoration ID is a [Sting] that must be +/// unique within a given bucket. To access the stored value again during state +/// restoration, the same restoration ID must be provided again. The owner of +/// the bucket may also make the bucket available to other entities so that they +/// can claim child buckets from it for their own restoration needs. Within a +/// bucket, child buckets are also identified by unique restoration IDs. The +/// restoration ID must be provided when claiming a child bucket. +/// +/// When restoration data is provided to the [RestorationManager] (e.g. after +/// the application relaunched when foregrounded again), the bucket hierarchy +/// with all the data stored in it is restored. Entities can retrieve the data +/// again by using the same restoration IDs that they originally used to store +/// the data. +/// +/// In addition to providing restoration data when the app is launched, +/// restoration data may also be provided to a running app to restore it to a +/// previous state (e.g. when the user hits the back/forward button in the web +/// browser). When this happens, the current bucket hierarchy is decommissioned +/// and replaced with the hierarchy deserialized from the newly provided +/// restoration data. Buckets in the old hierarchy notify their listeners when +/// they get decommissioned. In response to the notification, listeners must +/// stop using the old buckets. Owners of those buckets must dispose of them and +/// claim a new child as a replacement from a parent in the new bucket hierarchy +/// (that parent may be the updated [rootBucket]). +/// +/// Same platforms restrict the size of the restoration data. Therefore, the +/// data stored in the buckets should be as small as possible while still +/// allowing the app to restore its current state from it. Data that can be +/// retrieved from other services (e.g. a database or a web server) should not +/// be included in the restoration data. Instead, a small identifier (e.g. a +/// UUID, database record number, or resource locator) should be stored that can +/// be used to retrieve the data again from its original source during state +/// restoration. +/// +/// The [RestorationManager] sends a serialized version of the bucket hierarchy +/// over to the engine at the end of a frame in which the data in the hierarchy +/// or its shape has changed. The engine caches the data until the operating +/// system needs it. The application is responsible for keeping the data in the +/// bucket always up-to-date to reflect its current state. +/// +/// ## Discussion +/// +/// Due to Flutter's threading model and restrictions in the APIs of the +/// platforms Flutter runs on, restoration data must be stored in the buckets +/// proactively as described above. When the operating system asks for the +/// restoration data, it will do so on the platform thread expecting a +/// synchronous response. To avoid the risk of deadlocks, the platform thread +/// cannot block and call into the UI thread (where the dart code is running) to +/// retrieve the restoration data. For this reason, the [RestorationManager] +/// always sends the latest copy of the restoration data from the UI thread over +/// to the platform thread whenever it changes. That way, the restoration data +/// is always ready to go on the platform thread when the operating system needs +/// it. +/// +/// See also: +/// +/// * [ServicesBinding.restorationManager], which holds the singleton instance +/// of the [RestorationManager] for the currently running application. +/// * [RestorationBucket], which make up the restoration data hierarchy. +/// * [RestorationMixin], which uses [RestorationBucket]s behind the scenes +/// to make [State] objects of [StatefulWidget]s restorable. +class RestorationManager extends ChangeNotifier { + /// The root of the [RestorationBucket] hierarchy containing the restoration + /// data. + /// + /// Child buckets can be claimed from this bucket via + /// [RestorationBucket.claimChild]. If the [RestorationManager] has been asked + /// to restore the application to a previous state, these buckets will contain + /// the previously stored data. Otherwise the root bucket (and all children + /// claimed from it) will be empty. + /// + /// The [RestorationManager] informs its listeners (added via [addListener]) + /// when the value returned by this getter changes. This happens when new + /// restoration data has been provided to the [RestorationManager] to restore + /// the application to a different state. In response to the notification, + /// listeners must stop using the old root bucket and obtain the new one via + /// this getter ([rootBucket] will have been updated to return the new bucket + /// just before the listeners are notified). + /// + /// The restoration data describing the current bucket hierarchy is retrieved + /// asynchronously from the engine the first time the root bucket is accessed + /// via this getter. After the data has been copied over from the engine, this + /// getter will return a [SynchronousFuture], that immediately resolves to the + /// root [RestorationBucket]. + /// + /// The returned [Future] may resolve to null if state restoration is + /// currently turned off. + /// + /// See also: + /// + /// * [RootRestorationScope], which makes the root bucket available in the + /// [Widget] tree. + Future get rootBucket { + if (!SystemChannels.restoration.checkMethodCallHandler(_methodHandler)) { + SystemChannels.restoration.setMethodCallHandler(_methodHandler); + } + if (_rootBucketIsValid) { + return SynchronousFuture(_rootBucket); + } + if (_pendingRootBucket == null) { + _pendingRootBucket = Completer(); + _getRootBucketFromEngine(); + } + return _pendingRootBucket.future; + } + RestorationBucket _rootBucket; // May be null to indicate that restoration is turned off. + Completer _pendingRootBucket; + bool _rootBucketIsValid = false; + + Future _getRootBucketFromEngine() async { + final Map config = await SystemChannels.restoration.invokeMethod>('get'); + if (_pendingRootBucket == null) { + // The restoration data was obtained via other means (e.g. by calling + // [handleRestorationDataUpdate] while the request to the engine was + // outstanding. Ignore the engine's response. + return; + } + assert(_rootBucket == null); + _parseAndHandleRestorationUpdateFromEngine(config); + } + + void _parseAndHandleRestorationUpdateFromEngine(Map update) { + handleRestorationUpdateFromEngine( + enabled: update != null && update['enabled'] as bool, + data: update == null ? null : update['data'] as Uint8List, + ); + } + + /// Called by the [RestorationManager] on itself to parse the restoration + /// information obtained from the engine. + /// + /// The `enabled` parameter indicates whether the engine wants to receive + /// restoration data. When `enabled` is false, state restoration is turned + /// off and the [rootBucket] is set to null. When `enabled` is true, the + /// provided restoration `data` will be parsed into the [rootBucket]. If + /// `data` is null, an empty [rootBucket] will be instantiated. + /// + /// When this method is called, the old [rootBucket] is decommissioned. + /// + /// Subclasses in test frameworks may call this method at any time to inject + /// restoration data (obtained e.g. by overriding [sendToEngine]) into the + /// [RestorationManager]. When the method is called before the [rootBucket] is + /// accessed, [rootBucket] will complete synchronously the next time it is + /// called. + @protected + void handleRestorationUpdateFromEngine({@required bool enabled, @required Uint8List data}) { + assert(enabled != null); + + final RestorationBucket oldRoot = _rootBucket; + + _rootBucket = enabled + ? RestorationBucket.root(manager: this, rawData: _decodeRestorationData(data)) + : null; + _rootBucketIsValid = true; + assert(_pendingRootBucket == null || !_pendingRootBucket.isCompleted); + _pendingRootBucket?.complete(_rootBucket); + _pendingRootBucket = null; + + if (_rootBucket != oldRoot) { + notifyListeners(); + } + if (oldRoot != null) { + oldRoot + ..decommission() + ..dispose(); + } + } + + /// Called by the [RestorationManager] on itself to send the provided + /// encoded restoration data to the engine. + /// + /// The `encodedData` describes the entire bucket hierarchy that makes up the + /// current restoration data. + /// + /// Subclasses in test frameworks may override this method to capture the + /// restoration data that would have been send to the engine. The captured + /// data can be re-injected into the [RestorationManager] via the + /// [handleRestorationUpdateFromEngine] method to restore the state described + /// by the data. + @protected + Future sendToEngine(Uint8List encodedData) { + assert(encodedData != null); + return SystemChannels.restoration.invokeMethod( + 'put', + encodedData, + ); + } + + Future _methodHandler(MethodCall call) { + switch (call.method) { + case 'push': + _parseAndHandleRestorationUpdateFromEngine(call.arguments as Map); + break; + default: + throw UnimplementedError("${call.method} was invoked but isn't implemented by $runtimeType"); + } + return null; + } + + Map _decodeRestorationData(Uint8List data) { + if (data == null) { + return null; + } + final ByteData encoded = data.buffer.asByteData(data.offsetInBytes, data.lengthInBytes); + return const StandardMessageCodec().decodeMessage(encoded) as Map; + } + + Uint8List _encodeRestorationData(Map data) { + final ByteData encoded = const StandardMessageCodec().encodeMessage(data); + return encoded.buffer.asUint8List(encoded.offsetInBytes, encoded.lengthInBytes); + } + + bool _debugDoingUpdate = false; + bool _postFrameScheduled = false; + + final Set _bucketsNeedingSerialization = {}; + + /// Called by a [RestorationBucket] to request serialization for that bucket. + /// + /// This method is called by a bucket in the hierarchy whenever the data + /// in it or the shape of the hierarchy has changed. + /// + /// Calling this is a no-op when the bucket is already scheduled for + /// serialization. + /// + /// It is exposed to allow testing of [RestorationBucket]s in isolation. + @protected + @visibleForTesting + void scheduleSerializationFor(RestorationBucket bucket) { + assert(bucket != null); + assert(bucket._manager == this); + assert(!_debugDoingUpdate); + _bucketsNeedingSerialization.add(bucket); + if (!_postFrameScheduled) { + _postFrameScheduled = true; + SchedulerBinding.instance.addPostFrameCallback((Duration _) => _doSerialization()); + } + } + + /// Called by a [RestorationBucket] to unschedule a request for serialization. + /// + /// This method is called by a bucket in the hierarchy whenever it no longer + /// needs to be serialized (e.g. because the bucket got disposed). + /// + /// It is safe to call this even when the bucket wasn't scheduled for + /// serialization before. + /// + /// It is exposed to allow testing of [RestorationBucket]s in isolation. + @protected + @visibleForTesting + void unscheduleSerializationFor(RestorationBucket bucket) { + assert(bucket != null); + assert(bucket._manager == this); + assert(!_debugDoingUpdate); + _bucketsNeedingSerialization.remove(bucket); + } + + void _doSerialization() { + assert(() { + _debugDoingUpdate = true; + return true; + }()); + _postFrameScheduled = false; + + for (final RestorationBucket bucket in _bucketsNeedingSerialization) { + bucket.finalize(); + } + _bucketsNeedingSerialization.clear(); + sendToEngine(_encodeRestorationData(_rootBucket._rawData)); + + assert(() { + _debugDoingUpdate = false; + return true; + }()); + } +} + +/// A [RestorationBucket] holds pieces of the restoration data that a part of +/// the application needs to restore its state. +/// +/// For a general overview of how state restoration works in Flutter, see the +/// [RestorationManager]. +/// +/// [RestorationBucket]s are organized in a tree that is rooted in +/// [RestorationManager.rootBucket] and managed by a [RestorationManager]. The +/// tree is serializable and must contain all the data an application needs to +/// restore its current state at a later point in time. +/// +/// A [RestorationBucket] stores restoration data as key-value pairs. The key is +/// a [String] representing a restoration ID that identifies a piece of data +/// uniquely within a bucket. The value can be anything that is serializable via +/// the [StandardMessageCodec]. Furthermore, a [RestorationBucket] may have +/// child buckets, which are identified within their parent via a unique +/// restoration ID as well. +/// +/// During state restoration, the data previously stored in the +/// [RestorationBucket] hierarchy will be made available again to the +/// application to restore it to the state it had when the data was collected. +/// State restoration to a previous state may happen when the app is launched +/// (e.g. after it has been terminated gracefully while running in the +/// background) or after the app has already been running for a while. +/// +/// ## Lifecycle +/// +/// A [RestorationBucket] is rarely instantiated directly via its constructors. +/// Instead, when an entity wants to store data in or retrieve data from a +/// restoration bucket, it typically obtains a child bucket from a parent by +/// calling [claimChild]. If no parent is available, +/// [RestorationManager.rootBucket] may be used as a parent. When claiming a +/// child, the claimer must provide the restoration ID of the child it would +/// like to own. A child bucket with a given restoration ID can at most have +/// one owner. If another owner tries to claim a bucket with the same ID from +/// the same parent, an exception is thrown (see discussion in [claimChild]). +/// The restoration IDs that a given owner uses to claim a child (and to store +/// data in that child, see below) must be stable across app launches to ensure +/// that after the app restarts the owner can retrieve the same data again that +/// it stored during a previous run. +/// +/// Per convention, the owner of the bucket has exclusive access to the values +/// stored in the bucket. It can read, add, modify, and remove values via the +/// [read], [write], and [remove] methods. In general, the owner should store +/// all the data in the bucket that it needs to restore its current state. If +/// its current state changes, the data in the bucket must be updated. At the +/// same time, the data in the bucket should be kept to a minimum. For example, +/// for data that can be retrieved from other sources (like a database or +/// webservice) only enough information (e.g. an ID or resource locator) to +/// re-obtain that data should be stored in the bucket. In addition to managing +/// the data in a bucket, an owner may also make the bucket available to other +/// entities so they can claim child buckets from it via [claimChild] for their +/// own restoration needs. +/// +/// The bucket returned by [claimChild] may either contain state information +/// that the owner had previously (e.g. during a previous run of the +/// application) stored in it or it may be empty. If the bucket contains data, +/// the owner is expected to restore its state with the information previously +/// stored in the bucket. If the bucket is empty, it may initialize itself to +/// default values. +/// +/// During the lifetime of a bucket, it may notify its listeners that the bucket +/// has been [decommission]ed. This happens when new restoration data has been +/// provided to, for example, the [RestorationManager] to restore the +/// application to a different state (e.g. when the user hits the back/forward +/// button in the web browser). In response to the notification, owners must +/// dispose their current bucket and replace it with a new bucket claimed from a +/// new parent (which will have been initialized with the new restoration data). +/// For example, if the owner previously claimed its bucket from +/// [RestorationManager.rootBucket], it must claim its new bucket from there +/// again. The root bucket will have been replaced with the new root bucket just +/// before the bucket listeners are informed about the decommission. Once the +/// new bucket is obtained, owners should restore their internal state according +/// to the information in the new bucket. +/// +/// When the data stored in a bucket is no longer needed to restore the +/// application to its current state (e.g. because the owner of the bucket is no +/// longer shown on screen), the bucket must be [dispose]d. This will remove all +/// information stored in the bucket from the app's restoration data and that +/// information will not be available again when the application is restored to +/// this state in the future. +class RestorationBucket extends ChangeNotifier { + /// Creates an empty [RestorationBucket] to be provided to [adoptChild] to add + /// it to the bucket hierarchy. + /// + /// {@template flutter.services.restoration.bucketcreation} + /// Instantiating a bucket directly is rare, most buckets are created by + /// claiming a child from a parent via [claimChild]. If no parent bucket is + /// available, [RestorationManager.rootBucket] may be used as a parent. + /// {@endtemplate} + /// + /// The `restorationId` must not be null. + RestorationBucket.empty({ + @required String restorationId, + @required Object debugOwner, + }) : assert(restorationId != null), + _restorationId = restorationId, + _rawData = {} { + assert(() { + _debugOwner = debugOwner; + return true; + }()); + } + + /// Creates the root [RestorationBucket] for the provided restoration + /// `manager`. + /// + /// The `rawData` must either be null (in which case an empty bucket will be + /// instantiated) or it must be a nested map describing the entire bucket + /// hierarchy in the following format: + /// + /// ```javascript + /// { + /// 'v': { // key-value pairs + /// // * key is a string representation a restoration ID + /// // * value is any primitive that can be encoded with [StandardMessageCodec] + /// ': , + /// }, + /// 'c': { // child buckets + /// 'restoration-id': + /// } + /// } + /// ``` + /// + /// {@macro flutter.services.restoration.bucketcreation} + /// + /// The `manager` argument must not be null. + RestorationBucket.root({ + @required RestorationManager manager, + @required Map rawData, + }) : assert(manager != null), + _manager = manager, + _rawData = rawData ?? {}, + _restorationId = 'root' { + assert(() { + _debugOwner = manager; + return true; + }()); + } + + /// Creates a child bucket initialized with the data that the provided + /// `parent` has stored under the provided [id]. + /// + /// This constructor cannot be used if the `parent` does not have any child + /// data stored under the given ID. In that case, create an empty bucket (via + /// [RestorationBucket.empty] and have the parent adopt it via [adoptChild]. + /// + /// {@macro flutter.services.restoration.bucketcreation} + /// + /// The `restorationId` and `parent` argument must not be null. + RestorationBucket.child({ + @required String restorationId, + @required RestorationBucket parent, + @required Object debugOwner, + }) : assert(restorationId != null), + assert(parent != null), + assert(parent._rawChildren[restorationId] != null), + _manager = parent._manager, + _parent = parent, + _rawData = parent._rawChildren[restorationId] as Map, + _restorationId = restorationId { + assert(() { + _debugOwner = debugOwner; + return true; + }()); + } + + static const String _childrenMapKey = 'c'; + static const String _valuesMapKey = 'v'; + + final Map _rawData; + + /// The owner of the bucket that was provided when the bucket was claimed via + /// [claimChild]. + /// + /// The value is used in error messages. Accessing the value is only valid + /// in debug mode, otherwise it will return null. + Object get debugOwner { + assert(_debugAssertNotDisposed()); + return _debugOwner; + } + Object _debugOwner; + + RestorationManager _manager; + RestorationBucket _parent; + + /// The restoration ID under which the bucket is currently stored in the + /// parent of this bucket (or wants to be stored if it is currently + /// parent-less). + /// + /// This value is never null. + String get restorationId { + assert(_debugAssertNotDisposed()); + return _restorationId; + } + String _restorationId; + + // Maps a restoration ID to the raw map representation of a child bucket. + Map get _rawChildren => _rawData.putIfAbsent(_childrenMapKey, () => {}) as Map; + // Maps a restoration ID to a value that is stored in this bucket. + Map get _rawValues => _rawData.putIfAbsent(_valuesMapKey, () => {}) as Map; + + /// Called to signal that this bucket and all its descendants are no longer + /// part of the current restoration data and must not be used anymore. + /// + /// Calling this method will drop this bucket from its parent and notify all + /// its listeners as well as all listeners of its descendants. Once a bucket + /// has notified its listeners, it must not be used anymore. During the next + /// frame following the notification, the bucket must be disposed and replaced + /// with a new bucket. + /// + /// As an example, the [RestorationManager] calls this method on its root + /// bucket when it has been asked to restore a running application to a + /// different state. At that point, the data stored in the current bucket + /// hierarchy is invalid and will be replaced with a new hierarchy generated + /// from the restoration data describing the new state. To replace the current + /// bucket hierarchy, [decommission] is called on the root bucket to signal to + /// all owners of buckets in the hierarchy that their bucket has become + /// invalid. In response to the notification, bucket owners must [dispose] + /// their buckets and claim a new bucket from the newly created hierarchy. For + /// example, the owner of a bucket that was originally claimed from the + /// [RestorationManager.rootBucket] must dispose that bucket and claim a new + /// bucket from the new [RestorationManager.rootBucket]. Once the new bucket + /// is claimed, owners should restore their state according to the data stored + /// in the new bucket. + void decommission() { + assert(_debugAssertNotDisposed()); + if (_parent != null) { + _parent._dropChild(this); + _parent = null; + } + _performDecommission(); + } + + bool _decommissioned = false; + + void _performDecommission() { + _decommissioned = true; + _updateManager(null); + notifyListeners(); + _visitChildren((RestorationBucket bucket) { + bucket._performDecommission(); + }); + } + + // Get and store values. + + /// Returns the value of type `P` that is currently stored in the bucket under + /// the provided `restorationId`. + /// + /// Returns null if nothing is stored under that id. Throws, if the value + /// stored under the ID is not of type `P`. + /// + /// See also: + /// + /// * [write], which stores a value in the bucket. + /// * [remove], which removes a value from the bucket. + /// * [contains], which checks whether any value is stored under a given + /// restoration ID. + P read

(String restorationId) { + assert(_debugAssertNotDisposed()); + assert(restorationId != null); + return _rawValues[restorationId] as P; + } + + /// Stores the provided `value` of type `P` under the provided `restorationId` + /// in the bucket. + /// + /// Any value that has previously been stored under that ID is overwritten + /// with the new value. The provided `value` must be serializable with the + /// [StandardMessageCodec]. + /// + /// Null values will be stored in the bucket as-is. To remove a value, use + /// [remove]. + /// + /// See also: + /// + /// * [read], which retrieves a stored value from the bucket. + /// * [remove], which removes a value from the bucket. + /// * [contains], which checks whether any value is stored under a given + /// restoration ID. + void write

(String restorationId, P value) { + assert(_debugAssertNotDisposed()); + assert(restorationId != null); + assert(debugIsSerializableForRestoration(value)); + if (_rawValues[restorationId] != value || !_rawValues.containsKey(restorationId)) { + _rawValues[restorationId] = value; + _markNeedsSerialization(); + } + } + + /// Deletes the value currently stored under the provided `restorationId` from + /// the bucket. + /// + /// The value removed from the bucket is casted to `P` and returned. If no + /// value was stored under that id, null is returned. + /// + /// See also: + /// + /// * [read], which retrieves a stored value from the bucket. + /// * [write], which stores a value in the bucket. + /// * [contains], which checks whether any value is stored under a given + /// restoration ID. + P remove

(String restorationId) { + assert(_debugAssertNotDisposed()); + assert(restorationId != null); + final bool needsUpdate = _rawValues.containsKey(restorationId); + final P result = _rawValues.remove(restorationId) as P; + if (_rawValues.isEmpty) { + _rawData.remove(_valuesMapKey); + } + if (needsUpdate) { + _markNeedsSerialization(); + } + return result; + } + + /// Checks whether a value stored in the bucket under the provided + /// `restorationId`. + /// + /// See also: + /// + /// * [read], which retrieves a stored value from the bucket. + /// * [write], which stores a value in the bucket. + /// * [remove], which removes a value from the bucket. + bool contains(String restorationId) { + assert(_debugAssertNotDisposed()); + assert(restorationId != null); + return _rawValues.containsKey(restorationId); + } + + // Child management. + + // The restoration IDs and associated buckets of children that have been + // claimed via [claimChild]. + final Map _claimedChildren = {}; + // Newly created child buckets whose restoration ID is still in use, see + // comment in [claimChild] for details. + final Map> _childrenToAdd = >{}; + + /// Claims ownership of the child with the provided `restorationId` from this + /// bucket. + /// + /// If the application is getting restored to a previous state, the bucket + /// will contain all the data that was previously stored in the bucket. + /// Otherwise, an empty bucket is returned. + /// + /// The claimer of the bucket is expected to use the data stored in the bucket + /// to restore itself to its previous state described by the data in the + /// bucket. If the bucket is empty, it should initialize itself to default + /// values. Whenever the information that the claimer needs to restore its + /// state changes, the data in the bucket should be updated to reflect that. + /// + /// A child bucket with a given `restorationId` can only have one owner. If + /// another owner claims a child bucket with the same `restorationId` an + /// exception will be thrown at the end of the current frame unless the + /// previous owner has either deleted its bucket by calling [dispose] or has + /// moved it to a new parent via [adoptChild]. + /// + /// When the returned bucket is no longer needed, it must be [dispose]d to + /// delete the information stored in it from the app's restoration data. + RestorationBucket claimChild(String restorationId, {@required Object debugOwner}) { + assert(_debugAssertNotDisposed()); + assert(restorationId != null); + // There are three cases to consider: + // 1. Claiming an ID that has already been claimed. + // 2. Claiming an ID that doesn't yet exist in [_rawChildren]. + // 3. Claiming an ID that does exist in [_rawChildren] and hasn't been + // claimed yet. + // If an ID has already been claimed (case 1) the current owner may give up + // that ID later this frame and it can be re-used. In anticipation of the + // previous owner's surrender of the id, we return an empty bucket for this + // new claim and check in [_debugAssertIntegrity] that at the end of the + // frame the old owner actually did surrendered the id. + // Case 2 also requires the creation of a new empty bucket. + // In Case 3 we create a new bucket wrapping the existing data in + // [_rawChildren]. + + // Case 1+2: Adopt and return an empty bucket. + if (_claimedChildren.containsKey(restorationId) || !_rawChildren.containsKey(restorationId)) { + final RestorationBucket child = RestorationBucket.empty( + debugOwner: debugOwner, + restorationId: restorationId, + ); + adoptChild(child); + return child; + } + + // Case 3: Return bucket wrapping the existing data. + assert(_rawChildren[restorationId] != null); + final RestorationBucket child = RestorationBucket.child( + restorationId: restorationId, + parent: this, + debugOwner: debugOwner, + ); + _claimedChildren[restorationId] = child; + return child; + } + + /// Adopts the provided `child` bucket. + /// + /// The `child` will be dropped from its old parent, if it had one. + /// + /// The `child` is stored under its [id] in this bucket. If this bucket + /// already contains a child bucket under the same id, the owner of that + /// existing bucket must give it up (e.g. by moving the child bucket to a + /// different parent or by disposing it) before the end of the current frame. + /// Otherwise an exception indicating the illegal use of duplicated + /// restoration IDs will trigger in debug mode. + /// + /// No-op if the provided bucket is already a child of this bucket. + void adoptChild(RestorationBucket child) { + assert(_debugAssertNotDisposed()); + assert(child != null); + if (child._parent != this) { + child._parent?._removeChildData(child); + child._parent = this; + _addChildData(child); + if (child._manager != _manager) { + _recursivelyUpdateManager(child); + } + } + assert(child._parent == this); + assert(child._manager == _manager); + } + + void _dropChild(RestorationBucket child) { + assert(child != null); + assert(child._parent == this); + _removeChildData(child); + child._parent = null; + if (child._manager != null) { + child._updateManager(null); + child._visitChildren(_recursivelyUpdateManager); + } + } + + bool _needsSerialization = false; + void _markNeedsSerialization() { + assert(_manager != null || _decommissioned); + if (!_needsSerialization) { + _needsSerialization = true; + _manager?.scheduleSerializationFor(this); + } + } + + /// Called by the [RestorationManager] just before the data of the bucket + /// is serialized and send to the engine. + /// + /// It is exposed to allow testing of [RestorationBucket]s in isolation. + @visibleForTesting + void finalize() { + assert(_debugAssertNotDisposed()); + assert(_needsSerialization); + _needsSerialization = false; + assert(_debugAssertIntegrity()); + } + + void _recursivelyUpdateManager(RestorationBucket bucket) { + bucket._updateManager(_manager); + bucket._visitChildren(_recursivelyUpdateManager); + } + + void _updateManager(RestorationManager newManager) { + if (_manager == newManager) { + return; + } + if (_needsSerialization) { + _manager?.unscheduleSerializationFor(this); + } + _manager = newManager; + if (_needsSerialization && _manager != null) { + _needsSerialization = false; + _markNeedsSerialization(); + } + } + + bool _debugAssertIntegrity() { + assert(() { + if (_childrenToAdd.isEmpty) { + return true; + } + final List error = [ + ErrorSummary('Multiple owners claimed child RestorationBuckets with the same IDs.'), + ErrorDescription('The following IDs were claimed multiple times from the parent $this:') + ]; + for (final MapEntry> child in _childrenToAdd.entries) { + final String id = child.key; + final List buckets = child.value; + assert(buckets.isNotEmpty); + assert(_claimedChildren.containsKey(id)); + error.addAll([ + ErrorDescription(' * "$id" was claimed by:'), + ...buckets.map((RestorationBucket bucket) => ErrorDescription(' * ${bucket.debugOwner}')), + ErrorDescription(' * ${_claimedChildren[id].debugOwner} (current owner)'), + ]); + } + throw FlutterError.fromParts(error); + }()); + return true; + } + + void _removeChildData(RestorationBucket child) { + assert(child != null); + assert(child._parent == this); + if (_claimedChildren.remove(child.restorationId) == child) { + _rawChildren.remove(child.restorationId); + final List pendingChildren = _childrenToAdd[child.restorationId]; + if (pendingChildren != null) { + final RestorationBucket toAdd = pendingChildren.removeLast(); + _finalizeAddChildData(toAdd); + if (pendingChildren.isEmpty) { + _childrenToAdd.remove(child.restorationId); + } + } + if (_rawChildren.isEmpty) { + _rawData.remove(_childrenMapKey); + } + _markNeedsSerialization(); + return; + } + _childrenToAdd[child.restorationId]?.remove(child); + if (_childrenToAdd[child.restorationId]?.isEmpty == true) { + _childrenToAdd.remove(child.restorationId); + } + } + + void _addChildData(RestorationBucket child) { + assert(child != null); + assert(child._parent == this); + if (_claimedChildren.containsKey(child.restorationId)) { + // Delay addition until the end of the frame in the hopes that the current + // owner of the child with the same ID will have given up that child by + // then. + _childrenToAdd.putIfAbsent(child.restorationId, () => []).add(child); + _markNeedsSerialization(); + return; + } + _finalizeAddChildData(child); + _markNeedsSerialization(); + } + + void _finalizeAddChildData(RestorationBucket child) { + assert(_claimedChildren[child.restorationId] == null); + assert(_rawChildren[child.restorationId] == null); + _claimedChildren[child.restorationId] = child; + _rawChildren[child.restorationId] = child._rawData; + } + + void _visitChildren(_BucketVisitor visitor, {bool concurrentModification = false}) { + Iterable children = _claimedChildren.values + .followedBy(_childrenToAdd.values.expand((List buckets) => buckets)); + if (concurrentModification) { + children = children.toList(growable: false); + } + children.forEach(visitor); + } + + // Bucket management + + /// Changes the restoration ID under which the bucket is stored in its parent + /// to `newRestorationId`. + /// + /// No-op if the bucket is already stored under the provided id. + /// + /// If another owner has already claimed a bucket with the provided `newId` an + /// exception will be thrown at the end of the current frame unless the other + /// owner has deleted its bucket by calling [dispose], [rename]ed it using + /// another ID, or has moved it to a new parent via [adoptChild]. + void rename(String newRestorationId) { + assert(_debugAssertNotDisposed()); + assert(newRestorationId != null); + assert(_parent != null); + if (newRestorationId == restorationId) { + return; + } + _parent._removeChildData(this); + _restorationId = newRestorationId; + _parent._addChildData(this); + } + + /// Deletes the bucket and all the data stored in it from the bucket + /// hierarchy. + /// + /// After [dispose] has been called, the data stored in this bucket and its + /// children are no longer part of the app's restoration data. The data + /// originally stored in the bucket will not be available again when the + /// application is restored to this state in the future. It is up to the + /// owners of the children to either move them (via [adoptChild]) to a new + /// parent that is still part of the bucket hierarchy or to [dispose] of them + /// as well. + /// + /// This method must only be called by the object's owner. + @override + void dispose() { + assert(_debugAssertNotDisposed()); + _visitChildren(_dropChild, concurrentModification: true); + _claimedChildren.clear(); + _childrenToAdd.clear(); + _parent?._removeChildData(this); + _parent = null; + _updateManager(null); + super.dispose(); + _debugDisposed = true; + } + + @override + String toString() => '${objectRuntimeType(this, 'RestorationBucket')}(restorationId: $restorationId, owner: $debugOwner)'; + + bool _debugDisposed = false; + bool _debugAssertNotDisposed() { + assert(() { + if (_debugDisposed) { + throw FlutterError( + 'A $runtimeType was used after being disposed.\n' + 'Once you have called dispose() on a $runtimeType, it can no longer be used.' + ); + } + return true; + }()); + return true; + } +} + +/// Returns true when the provided `object` is serializable for state +/// restoration. +/// +/// Should only be called from within asserts. Always returns false outside +/// of debug builds. +bool debugIsSerializableForRestoration(Object object) { + bool result = false; + + assert(() { + try { + const StandardMessageCodec().encodeMessage(object); + result = true; + } catch (_) { + result = false; + } + return true; + }()); + + return result; +} diff --git a/packages/flutter/lib/src/services/system_channels.dart b/packages/flutter/lib/src/services/system_channels.dart index ab9dd1202a4..5dde83f7d07 100644 --- a/packages/flutter/lib/src/services/system_channels.dart +++ b/packages/flutter/lib/src/services/system_channels.dart @@ -286,4 +286,35 @@ class SystemChannels { 'flutter/mousecursor', StandardMethodCodec(), ); + + /// A [MethodChannel] for synchronizing restoration data with the engine. + /// + /// The following outgoing methods are defined for this channel (invoked using + /// [OptionalMethodChannel.invokeMethod]): + /// + /// * `get`: Retrieves the current restoration information (e.g. provided by + /// the operating system) from the engine. The method returns a map + /// containing an `enabled` boolean to indicate whether collecting + /// restoration data is supported by the embedder. If `enabled` is true, + /// the map may also contain restoration data stored under the `data` key + /// from which the state of the framework may be restored. The restoration + /// data is encoded as [Uint8List]. + /// * `put`: Sends the current restoration data to the engine. Takes the + /// restoration data encoded as [Uint8List] as argument. + /// + /// The following incoming methods are defined for this channel (registered + /// using [MethodChannel.setMethodCallHandler]). + /// + /// * `push`: Called by the engine to send newly provided restoration + /// information to the framework. The argument given to this method has + /// the same format as the object that the `get` method returns. + /// + /// See also: + /// + /// * [RestorationManager], which uses this channel and also describes how + /// restoration data is used in Flutter. + static const MethodChannel restoration = OptionalMethodChannel( + 'flutter/restoration', + StandardMethodCodec(), + ); } diff --git a/packages/flutter/lib/src/widgets/restoration.dart b/packages/flutter/lib/src/widgets/restoration.dart new file mode 100644 index 00000000000..0a556890e04 --- /dev/null +++ b/packages/flutter/lib/src/widgets/restoration.dart @@ -0,0 +1,1053 @@ +// Copyright 2014 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. + +// @dart = 2.8 + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; + +import 'basic.dart'; +import 'framework.dart'; + +export 'package:flutter/services.dart' show RestorationBucket; + +/// Creates a new scope for restoration IDs used by descendant widgets to claim +/// [RestorationBucket]s. +/// +/// {@template flutter.widgets.restoration.scope} +/// A restoration scope inserts a [RestorationBucket] into the widget tree, +/// which descendant widgets can access via [RestorationScope.of]. It is +/// uncommon for descendants to directly store data in this bucket. Instead, +/// descendant widgets should consider storing their own restoration data in a +/// child bucket claimed with [RestorationBucket.claimChild] from the bucket +/// provided by this scope. +/// {@endtemplate} +/// +/// The bucket inserted into the widget tree by this scope has been claimed from +/// the surrounding [RestorationScope] using the provided [restorationId]. If +/// the [RestorationScope] is moved to a different part of the widget tree under +/// a different [RestorationScope], the bucket owned by this scope with all its +/// children and the data contained in them is moved to the new scope as well. +/// +/// This widget will not make a [RestorationBucket] available to descendants if +/// [restorationId] is null or when there is no surrounding restoration scope to +/// claim a bucket from. In this case, descendant widgets invoking +/// [RestorationScope.of] will receive null as a return value indicating that no +/// bucket is available for storing restoration data. This will turn off state +/// restoration for the widget subtree. +/// +/// See also: +/// +/// * [RootRestorationScope], which inserts the root bucket provided by +/// the [RestorationManager] into the widget tree and makes it accessible +/// for descendants via [RestorationScope.of]. +/// * [UnmanagedRestorationScope], which inserts a provided [RestorationBucket] +/// into the widget tree and makes it accessible for descendants via +/// [RestorationScope.of]. +/// * [RestorationMixin], which may be used in [State] objects to manage the +/// restoration data of a [StatefulWidget] instead of manually interacting +/// with [RestorationScope]s and [RestorationBucket]s. +/// * [RestorationManager], which describes the basic concepts of state +/// restoration in Flutter. +class RestorationScope extends StatefulWidget { + /// Creates a [RestorationScope]. + /// + /// Providing null as the [restorationId] turns off state restoration for + /// the [child] and its descendants. + /// + /// The [child] must not be null. + const RestorationScope({ + Key key, + @required this.restorationId, + @required this.child, + }) : assert(child != null), + super(key: key); + + /// Returns the [RestorationBucket] inserted into the widget tree by the + /// closest ancestor [RestorationScope] of `context`. + /// + /// To avoid accidentally overwriting data already stored in the bucket by its + /// owner, data should not be stored directly in the bucket returned by this + /// method. Instead, consider claiming a child bucket from the returned bucket + /// (via [RestorationBucket.claimChild]) and store the restoration data in + /// that child. + /// + /// This method returns null if state restoration is turned off for this + /// subtree. + static RestorationBucket of(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType()?.bucket; + } + + /// The widget below this widget in the tree. + /// + /// {@macro flutter.widgets.child} + final Widget child; + + /// The restoration ID used by this widget to obtain a child bucket from the + /// surrounding [RestorationScope]. + /// + /// The child bucket obtained from the surrounding scope is made available to + /// descendant widgets via [RestorationScope.of]. + /// + /// If this is null, [RestorationScope.of] invoked by descendants will return + /// null which effectively turns off state restoration for this subtree. + final String restorationId; + + @override + State createState() => _RestorationScopeState(); +} + +class _RestorationScopeState extends State with RestorationMixin { + @override + String get restorationId => widget.restorationId; + + @override + void restoreState(RestorationBucket oldBucket) { + // Nothing to do. + // The bucket gets injected into the widget tree in the build method. + } + + @override + Widget build(BuildContext context) { + return UnmanagedRestorationScope( + bucket: bucket, // `bucket` is provided by the RestorationMixin. + child: widget.child, + ); + } +} + +/// Inserts a provided [RestorationBucket] into the widget tree and makes it +/// available to descendants via [RestorationScope.of]. +/// +/// {@macro flutter.widgets.restoration.scope} +/// +/// If [bucket] is null, no restoration bucket is made available to descendant +/// widgets ([RestorationScope.of] invoked from a descendant will return null). +/// This effectively turns off state restoration for the subtree because no +/// bucket for storing restoration data is made available. +/// +/// See also: +/// +/// * [RestorationScope], which inserts a bucket obtained from a surrounding +/// restoration scope into the widget tree and makes it accessible +/// for descendants via [RestorationScope.of]. +/// * [RootRestorationScope], which inserts the root bucket provided by +/// the [RestorationManager] into the widget tree and makes it accessible +/// for descendants via [RestorationScope.of]. +/// * [RestorationMixin], which may be used in [State] objects to manage the +/// restoration data of a [StatefulWidget] instead of manually interacting +/// with [RestorationScope]s and [RestorationBucket]s. +/// * [RestorationManager], which describes the basic concepts of state +/// restoration in Flutter. +class UnmanagedRestorationScope extends InheritedWidget { + /// Creates an [UnmanagedRestorationScope]. + /// + /// When [bucket] is null state restoration is turned off for the [child] and + /// its descendants. + /// + /// The [child] must not be null. + const UnmanagedRestorationScope({ + Key key, + this.bucket, + @required Widget child, + }) : assert(child != null), + super(key: key, child: child); + + /// The [RestorationBucket] that this widget will insert into the widget tree. + /// + /// Descendant widgets may obtain this bucket via [RestorationScope.of]. + final RestorationBucket bucket; + + @override + bool updateShouldNotify(UnmanagedRestorationScope oldWidget) { + return oldWidget.bucket != bucket; + } +} + +/// Inserts a child bucket of [RestorationManager.rootBucket] into the widget +/// tree and makes it available to descendants via [RestorationScope.of]. +/// +/// This widget is usually used near the root of the widget tree to enable the +/// state restoration functionality for the application. For all other use +/// cases, consider using a regular [RestorationScope] instead. +/// +/// The root restoration bucket can only be retrieved asynchronously from the +/// [RestorationManager]. To ensure that the provided [child] has its +/// restoration data available the first time it builds, the +/// [RootRestorationScope] will build an empty [Container] instead of the actual +/// [child] until the root bucket is available. To hide the empty container from +/// the eyes of users, the [RootRestorationScope] also delays rendering the +/// first frame while the container is shown. On platforms that show a splash +/// screen on app launch the splash screen is kept up (hiding the empty +/// container) until the bucket is available and the [child] is ready to be +/// build. +/// +/// The exact behavior of this widget depends on its ancestors: When the +/// [RootRestorationScope] does not find an ancestor restoration bucket via +/// [RestorationScope.of] it will claim a child bucket from the root restoration +/// bucket ([RestorationManager.rootBucket]) using the provided [restorationId] +/// and inserts that bucket into the widget tree where descendants may access it +/// via [RestorationScope.of]. If the [RootRestorationScope] finds a non-null +/// ancestor restoration bucket via [RestorationScope.of] it will behave like a +/// regular [RestorationScope] instead: It will claim a child bucket from that +/// ancestor and insert that child into the widget tree. +/// +/// Unlike the [RestorationScope] widget, the [RootRestorationScope] will +/// guarantee that descendants have a bucket available for storing restoration +/// data as long as [restorationId] is not null and [RestorationManager] is +/// able to provide a root bucket. In other words, it will force-enable +/// state restoration for the subtree if [restorationId] is not null. +/// +/// If [restorationId] is null, no bucket is made available to descendants, +/// which effectively turns off state restoration for this subtree. +/// +/// See also: +/// +/// * [RestorationScope], which inserts a bucket obtained from a surrounding +/// restoration scope into the widget tree and makes it accessible +/// for descendants via [RestorationScope.of]. +/// * [UnmanagedRestorationScope], which inserts a provided [RestorationBucket] +/// into the widget tree and makes it accessible for descendants via +/// [RestorationScope.of]. +/// * [RestorationMixin], which may be used in [State] objects to manage the +/// restoration data of a [StatefulWidget] instead of manually interacting +/// with [RestorationScope]s and [RestorationBucket]s. +/// * [RestorationManager], which describes the basic concepts of state +/// restoration in Flutter. +class RootRestorationScope extends StatefulWidget { + /// Creates a [RootRestorationScope]. + /// + /// Providing null as the [restorationId] turns off state restoration for + /// the [child] and its descendants. + /// + /// The [child] must not be null. + const RootRestorationScope({ + Key key, + @required this.restorationId, + @required this.child, + }) : assert(child != null), + super(key: key); + + /// The widget below this widget in the tree. + /// + /// {@macro flutter.widgets.child} + final Widget child; + + /// The restoration ID used to identify the child bucket that this widget + /// will insert into the tree. + /// + /// If this is null, no bucket is made available to descendants and state + /// restoration for the subtree is essentially turned off. + final String restorationId; + + @override + State createState() => _RootRestorationScopeState(); +} + +class _RootRestorationScopeState extends State { + bool _okToRenderBlankContainer; + bool _rootBucketValid = false; + RestorationBucket _rootBucket; + RestorationBucket _ancestorBucket; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _ancestorBucket = RestorationScope.of(context); + _loadRootBucketIfNecessary(); + _okToRenderBlankContainer ??= widget.restorationId != null && _needsRootBucketInserted; + } + + @override + void didUpdateWidget(RootRestorationScope oldWidget) { + super.didUpdateWidget(oldWidget); + _loadRootBucketIfNecessary(); + } + + bool get _needsRootBucketInserted => _ancestorBucket == null; + + bool get _isWaitingForRootBucket { + return widget.restorationId != null && _needsRootBucketInserted && !_rootBucketValid; + } + + bool _isLoadingRootBucket = false; + + void _loadRootBucketIfNecessary() { + if (_isWaitingForRootBucket && !_isLoadingRootBucket) { + _isLoadingRootBucket = true; + RendererBinding.instance.deferFirstFrame(); + ServicesBinding.instance.restorationManager.rootBucket.then((RestorationBucket bucket) { + _isLoadingRootBucket = false; + if (mounted) { + ServicesBinding.instance.restorationManager.addListener(_replaceRootBucket); + setState(() { + _rootBucket = bucket; + _rootBucketValid = true; + _okToRenderBlankContainer = false; + }); + } + RendererBinding.instance.allowFirstFrame(); + }); + } + } + + void _replaceRootBucket() { + _rootBucketValid = false; + _rootBucket = null; + ServicesBinding.instance.restorationManager.removeListener(_replaceRootBucket); + _loadRootBucketIfNecessary(); + assert(!_isWaitingForRootBucket); // Ensure that load finished synchronously. + } + + @override + void dispose() { + ServicesBinding.instance.restorationManager.removeListener(_replaceRootBucket); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (_okToRenderBlankContainer && _isWaitingForRootBucket) { + return const SizedBox.shrink(); + } + + return UnmanagedRestorationScope( + bucket: _ancestorBucket ?? _rootBucket, + child: RestorationScope( + restorationId: widget.restorationId, + child: widget.child, + ), + ); + } +} + +/// Manages an object of type `T`, whose value a [State] object wants to have +/// restored during state restoration. +/// +/// The property wraps an object of type `T`. It knows how to store its value in +/// the restoration data and it knows how to re-instantiate that object from the +/// information it previously stored in the restoration data. +/// +/// The knowledge of how to store the wrapped object in the restoration data is +/// encoded in the [toPrimitives] method and the knowledge of how to +/// re-instantiate the object from that data is encoded in the [fromPrimitives] +/// method. A call to [toPrimitives] must return a representation of the wrapped +/// object that can be serialized with the [StandardMessageCodec]. If any +/// collections (e.g. [List]s, [Map]s, etc.) are returned, they must not be +/// modified after they have been returned from [toPrimitives]. At a later point +/// in time (which may be after the application restarted), the data obtained +/// from [toPrimitives] may be handed back to the property's [fromPrimitives] +/// method to restore it to the previous state described by that data. +/// +/// A [RestorableProperty] needs to be registered to a [RestorationMixin] using +/// a restoration ID that is unique within the mixin. The [RestorationMixin] +/// provides and manages the [RestorationBucket], in which the data returned by +/// [toPrimitives] is stored. +/// +/// Whenever the value returned by [toPrimitives] (or the [enabled] getter) +/// changes, the [RestorableProperty] must call [notifyListeners]. This will +/// trigger the [RestorationMixin] to update the data it has stored for the +/// property in its [RestorationBucket] to the latest information returned by +/// [toPrimitives]. +/// +/// When the property is registered with the [RestorationMixin], the mixin +/// checks whether there is any restoration data available for the property. If +/// data is available, the mixin calls [fromPrimitives] on the property, which +/// must return an object that matches the object the property wrapped when the +/// provided restoration data was obtained from [toPrimitives]. If no +/// restoration data is available to restore the property's wrapped object from, +/// the mixin calls [createDefaultValue]. The value returned by either of those +/// methods is then handed to the property's [initWithValue] method. +/// +/// Usually, subclasses of [RestorableProperty] hold on to the value provided to +/// them in [initWithValue] and make it accessible to the [State] object that +/// owns the property. This [RestorableProperty] base class, however, has no +/// opinion about what to do with the value provided to [initWithValue]. +/// +/// The [RestorationMixin] may call [fromPrimitives]/[createDefaultValue] +/// followed by [initWithValue] multiple times throughout the life of a +/// [RestorableProperty]: Whenever new restoration data is made available to the +/// [RestorationMixin] the property is registered with, the cycle of calling +/// [fromPrimitives] (if the new restoration data contains information to +/// restore the property from) or [createDefaultValue] (if no information for +/// the property is available in the new restoration data) followed by a call to +/// [initWithValue] repeats. Whenever [initWithValue] is called, the property +/// should forget the old value it was wrapping and re-initialize itself with +/// the newly provided value. +/// +/// In a typical use case, a subclass of [RestorableProperty] is instantiated +/// either to initialize a member variable of a [State] object or within +/// [State.initState]. It is then registered to a [RestorationMixin] in +/// [RestorationMixin.restoreState] and later [dispose]ed in [State.dispose]. +/// For less common use cases (e.g. if the value stored in a +/// [RestorableProperty] is only needed while the [State] object is in a certain +/// state), a [RestorableProperty] may be registered with a [RestorationMixin] +/// any time after [RestorationMixin.restoreState] has been called for the first +/// time. A [RestorableProperty] may also be unregistered from a +/// [RestorationMixin] before the owning [State] object is disposed by calling +/// [RestorationMixin.unregisterFromRestoration]. This is uncommon, though, and +/// will delete the information that the property contributed from the +/// restoration data (meaning the value of the property will no longer be +/// restored during a future state restoration). +/// +/// See also: +/// +/// * [RestorableValue], which is a [RestorableProperty] that makes the wrapped +/// value accessible to the owning [State] object via a `value` +/// getter and setter. +/// * [RestorationMixin], to which a [RestorableProperty] must be registered. +/// * [RestorationManager], which describes how state restoration works in +/// Flutter. +abstract class RestorableProperty extends ChangeNotifier { + /// Called by the [RestorationMixin] if no restoration data is available to + /// restore the value of the property from to obtain the default value for the + /// property. + /// + /// The method returns the default value that the property should wrap if no + /// restoration data is available. After this is called, [initWithValue] will + /// be called with this method's return value. + /// + /// The method may be called multiple times throughout the life of the + /// [RestorableProperty]. Whenever new restoration data has been provided to + /// the [RestorationMixin] the property is registered to, either this method + /// or [fromPrimitives] is called before [initWithValue] is invoked. + T createDefaultValue(); + + /// Called by the [RestorationMixin] to convert the `data` previously + /// retrieved from [toPrimitives] back into an object of type `T` that this + /// property should wrap. + /// + /// The object returned by this method is passed to [initWithValue] to restore + /// the value that this property is wrapping to the value described by the + /// provided `data`. + /// + /// The method may be called multiple times throughout the life of the + /// [RestorableProperty]. Whenever new restoration data has been provided to + /// the [RestorationMixin] the property is registered to, either this method + /// or [createDefaultValue] is called before [initWithValue] is invoked. + T fromPrimitives(Object data); + + /// Called by the [RestorationMixin] with the `value` returned by either + /// [createDefaultValue] or [fromPrimitives] to set the value that this + /// property currently wraps. + /// + /// The [initWithValue] method may be called multiple times throughout the + /// life of the [RestorableProperty] whenever new restoration data has been + /// provided to the [RestorationMixin] the property is registered to. When + /// [initWithValue] is called, the property should forget its previous value + /// and re-initialize itself to the newly provided `value`. + void initWithValue(T value); + + /// Called by the [RestorationMixin] to retrieve the information that this + /// property wants to store in the restoration data. + /// + /// The returned object must be serializable with the [StandardMessageCodec] + /// and if it includes any collections, those should not be modified after + /// they have been returned by this method. + /// + /// The information returned by this method may be handed back to the property + /// in a call to [fromPrimitives] at a later point in time (possibly after the + /// application restarted) to restore the value that the property is currently + /// wrapping. + /// + /// When the value returned by this method changes, the property must call + /// [notifyListeners]. The [RestorationMixin] will invoke this method whenever + /// the property's listeners are notified. + Object toPrimitives(); + + /// Whether the object currently returned by [toPrimitives] should be included + /// in the restoration state. + /// + /// When this returns false, no information is included in the restoration + /// data for this property and the property will be initialized to its default + /// value (obtained from [createDefaultValue]) the next time that restoration + /// data is used for state restoration. + /// + /// Whenever the value returned by this getter changes, [notifyListeners] must + /// be called. When the value changes from true to false, the information last + /// retrieved from [toPrimitives] is removed from the restoration data. When + /// it changes from false to true, [toPrimitives] is invoked to add the latest + /// restoration information provided by this property to the restoration data. + bool get enabled => true; + + bool _disposed = false; + + @override + void dispose() { + assert(_debugAssertNotDisposed()); + _owner?._unregister(this); + super.dispose(); + _disposed = true; + } + + // ID under which the property has been registered with the RestorationMixin. + String _restorationId; + RestorationMixin _owner; + void _register(String restorationId, RestorationMixin owner) { + assert(_debugAssertNotDisposed()); + assert(restorationId != null); + assert(owner != null); + _restorationId = restorationId; + _owner = owner; + } + void _unregister() { + assert(_debugAssertNotDisposed()); + assert(_restorationId != null); + assert(_owner != null); + _restorationId = null; + _owner = null; + } + + /// The [State] object that this property is registered with. + @protected + State get state { + assert(_debugAssertNotDisposed()); + return _owner; + } + + /// Whether this property is currently registered with a [RestorationMixin]. + @protected + bool get isRegistered { + assert(_debugAssertNotDisposed()); + return _restorationId != null; + } + + bool _debugAssertNotDisposed() { + assert(() { + if (_disposed) { + throw FlutterError( + 'A $runtimeType was used after being disposed.\n' + 'Once you have called dispose() on a $runtimeType, it can no longer be used.' + ); + } + return true; + }()); + return true; + } +} + +/// Manages the restoration data for a [State] object of a [StatefulWidget]. +/// +/// Restoration data can be serialized out and, at a later point in time, be +/// used to restore the stateful members in the [State] object to the same +/// values they had when the data was generated. +/// +/// This mixin organizes the restoration data of a [State] object in +/// [RestorableProperty]. All the information that the [State] object wants to +/// get restored during state restoration need to be saved in a subclass of +/// [RestorableProperty]. For example, to restore the count value in a counter +/// app, that value should be stored in a member variable of type +/// [RestorableInt] instead of a plain member variable of type [int]. +/// +/// The mixin ensures that the current values of the [RestorableProperty]s are +/// serialized as part of the restoration state. It is up to the [State] to +/// ensure that the data stored in the properties is always up to date. When the +/// widget is restored from previously generated restoration data, the values of +/// the [RestorableProperty]s are automatically restored to the values that had +/// when the restoration data was serialized out. +/// +/// Within a [State] that uses this mixin, [RestorableProperty]s are usually +/// instantiated to initialize member variables. Users of the mixin must +/// override [restoreState] and register their previously instantiated +/// [RestorableProperty]s in this method by calling [registerForRestoration]. +/// The mixin calls this method for the first time right after +/// [State.initState]. After registration, the values stored in the property +/// have either been restored to their previous value or - if no restoration +/// data for restoring is available - they are initialized with a +/// property-specific default value. At the end of a [State] object's life +/// cycle, all restorable properties must be disposed in [State.dispose]. +/// +/// In addition to being invoked right after [State.initState], [restoreState] +/// is invoked again when new restoration data has been provided to the mixin. +/// When this happens, the [State] object must re-register all properties with +/// [registerForRestoration] again to restore them to their previous values as +/// described by the new restoration data. All initialization logic that depends +/// on the current value of a restorable property should be included in the +/// [restoreState] method to ensure it re-executes when the properties are +/// restored to a different value during the life time of the [State] object. +/// +/// Internally, the mixin stores the restoration data from all registered +/// properties in a [RestorationBucket] claimed from the surrounding +/// [RestorationScope] using the [State]-provided [restorationId]. The +/// [restorationId] must be unique in the surrounding [RestorationScope]. State +/// restoration is disabled for the [State] object using this mixin if +/// [restorationId] is null or when there is no surrounding [RestorationScope]. +/// In that case, the values of the registered properties will not be restored +/// during state restoration. +/// +/// The [RestorationBucket] used to store the registered properties is available +/// via the [bucket] getter. Interacting directly with the bucket is uncommon, +/// but the [State] object may make this bucket available for its descendants to +/// claim child buckets from. For that, the [bucket] is injected into the widget +/// tree in [State.build] with the help of an [UnmanagedRestorationScope]. +/// +/// The [bucket] getter returns null if state restoration is turned off. If +/// state restoration is turned on or off during the lifetime of the widget +/// (e.g. because [restorationId] changes from null to non-null) the value +/// returned by the getter will also change from null to non-null or vice versa. +/// The mixin calls [didToggleBucket] on itself to notify the [State] object +/// about this change. Overriding this method is not necessary as long as the +/// [State] object does not directly interact with the [bucket]. +/// +/// Whenever the value returned by [restorationId] changes, +/// [didUpdateRestorationId] must be called (unless the change already triggers +/// a call to [didUpdateWidget]). +/// +/// {@tool dartpad --template=freeform} +/// This example demonstrates how to make a simple counter app restorable by +/// using the [RestorationMixin] and a [RestorableInt]. +/// +/// ```dart imports +/// import 'package:flutter/material.dart'; +/// ``` +/// +/// ```dart main +/// void main() => runApp(RestorationExampleApp()); +/// ``` +/// +/// ```dart preamble +/// class RestorationExampleApp extends StatelessWidget { +/// @override +/// Widget build(BuildContext context) { +/// // TODO(goderbauer): remove the [RootRestorationScope] once it is part of [MaterialApp]. // ignore: flutter_style_todos +/// return RootRestorationScope( +/// restorationId: 'root', +/// child: MaterialApp( +/// title: 'Restorable Counter', +/// home: RestorableCounter(restorationId: 'counter'), +/// ), +/// ); +/// } +/// } +/// ``` +/// +/// ```dart +/// class RestorableCounter extends StatefulWidget { +/// RestorableCounter({Key key, this.restorationId}) : super(key: key); +/// +/// final String restorationId; +/// +/// @override +/// _RestorableCounterState createState() => _RestorableCounterState(); +/// } +/// +/// // The [State] object uses the [RestorationMixin] to make the current value +/// // of the counter restorable. +/// class _RestorableCounterState extends State with RestorationMixin { +/// // The current value of the counter is stored in a [RestorableProperty]. +/// // During state restoration it is automatically restored to its old value. +/// // If no restoration data is available to restore the counter from, it is +/// // initialized to the specified default value of zero. +/// RestorableInt _counter = RestorableInt(0); +/// +/// // In this example, the restoration ID for the mixin is passed in through +/// // the [StatefulWidget]'s constructor. +/// @override +/// String get restorationId => widget.restorationId; +/// +/// @override +/// void restoreState(RestorationBucket oldBucket) { +/// // All restorable properties must be registered with the mixin. After +/// // registration, the counter either has its old value restored or is +/// // initialized to its default value. +/// registerForRestoration(_counter, 'count'); +/// } +/// +/// void _incrementCounter() { +/// setState(() { +/// // The current value of the property can be accessed and modified via +/// // the value getter and setter. +/// _counter.value++; +/// }); +/// } +/// +/// @override +/// Widget build(BuildContext context) { +/// return Scaffold( +/// appBar: AppBar( +/// title: Text('Restorable Counter'), +/// ), +/// body: Center( +/// child: Column( +/// mainAxisAlignment: MainAxisAlignment.center, +/// children: [ +/// Text( +/// 'You have pushed the button this many times:', +/// ), +/// Text( +/// '${_counter.value}', +/// style: Theme.of(context).textTheme.headline4, +/// ), +/// ], +/// ), +/// ), +/// floatingActionButton: FloatingActionButton( +/// onPressed: _incrementCounter, +/// tooltip: 'Increment', +/// child: Icon(Icons.add), +/// ), +/// ); +/// } +/// } +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [RestorableProperty], which is the base class for all restoration +/// properties managed by this mixin. +/// * [RestorationManager], which describes how state restoration in Flutter +/// works. +/// * [RestorationScope], which creates a new namespace for restoration IDs +/// in the widget tree. +@optionalTypeArgs +mixin RestorationMixin on State { + /// The restoration ID used for the [RestorationBucket] in which the mixin + /// will store the restoration data of all registered properties. + /// + /// The restoration ID is used to claim a child [RestorationScope] from the + /// surrounding [RestorationScope] (accessed via [RestorationScope.of]) and + /// the ID must be unique in that scope (otherwise an exception is triggered + /// in debug move). + /// + /// State restoration for this mixin is turned off when this getter returns + /// null or when there is no surrounding [RestorationScope] available. When + /// state restoration is turned off, the values of the registered properties + /// cannot be restored. + /// + /// Whenever the value returned by this getter changes, + /// [didUpdateRestorationId] must be called unless the (unless the change + /// already triggered a call to [didUpdateWidget]). + /// + /// The restoration ID returned by this getter is often provided in the + /// constructor of the [StatefulWidget] that this [State] object is associated + /// with. + @protected + String get restorationId; + + /// The [RestorationBucket] used for the restoration data of the + /// [RestorableProperty]s registered to this mixin. + /// + /// The bucket has been claimed from the surrounding [RestorationScope] using + /// [restorationId]. + /// + /// The getter returns null if state restoration is turned off. When state + /// restoration is turned on or off during the lifetime of this mixin (and + /// hence the return value of this getter switches between null and non-null) + /// [didToggleBucket] is called. + /// + /// Interacting directly with this bucket is uncommon. However, the bucket may + /// be injected into the widget tree in the [State]'s `build` method using an + /// [UnmanagedRestorationScope]. That allows descendants to claim child + /// buckets from this bucket for their own restoration needs. + RestorationBucket get bucket => _bucket; + RestorationBucket _bucket; + + /// Called to initialize or restore the [RestorableProperty]s used by the + /// [State] object. + /// + /// This method is always invoked at least once right after [State.initState] + /// to register the [RestorableProperty]s with the mixin even when state + /// restoration is turned off or no restoration data is available for this + /// [State] object. + /// + /// Typically, [registerForRestoration] is called from this method to register + /// all [RestorableProperty]s used by the [State] object with the mixin. The + /// registration will either restore the property's value to the value + /// described by the restoration data, if available, or, if no restoration + /// data is available - initialize it to a property-specific default value. + /// + /// The method is called again whenever new restoration data (in the form of a + /// new [bucket]) has been provided to the mixin. When that happens, the + /// [State] object must re-register all previously registered properties, + /// which will restore their values to the value described by the new + /// restoration data. + /// + /// Since the method may change the value of the registered properties when + /// new restoration state is provided, all initialization logic that depends + /// on a specific value of a [RestorableProperty] should be included in this + /// method. That way, that logic re-executes when the [RestorableProperty]s + /// have their values restored from newly provided restoration data. + /// + /// The first time the method is invoked, the provided `oldBucket` argument is + /// always null. In subsequent calls triggered by new restoration data in the + /// form of a new bucket, the argument given is the previous value of + /// [bucket]. + @mustCallSuper + @protected + void restoreState(RestorationBucket oldBucket); + + /// Called when [bucket] switches between null and non-null values. + /// + /// [State] objects that wish to directly interact with the bucket may + /// override this method to store additional values in the bucket when one + /// becomes available or to save values stored in a bucket elsewhere when the + /// bucket goes away. This is uncommon and storing those values in + /// [RestorableProperty]s should be considered instead. + /// + /// The `oldBucket` is provided to the method when the [bucket] getter changes + /// from non-null to null. The `oldBucket` argument is null when the [bucket] + /// changes from null to non-null. + /// + /// See also: + /// + /// * [restoreState], which is called when the [bucket] changes from one + /// non-null value to another non-null value. + @mustCallSuper + @protected + void didToggleBucket(RestorationBucket oldBucket) { + // When restore is pending, restoreState must be called instead. + assert(!restorePending); + } + + // Maps properties to their listeners. + final Map, VoidCallback> _properties = , VoidCallback>{}; + + /// Registers a [RestorableProperty] for state restoration. + /// + /// The registration associates the provided `property` with the provided + /// `restorationId`. If restoration data is available for the provided + /// `restorationId`, the property's value is restored to the value described + /// by the restoration data. If no restoration data is available, the property + /// will be initialized to a property-specific default value. + /// + /// Each property within a [State] object must be registered under a unique + /// ID. Only registered properties will have their values restored during + /// state restoration. + /// + /// Typically, this method is called from within [restoreState] to register + /// all restorable properties of the owning [State] object. However, if a + /// given [RestorableProperty] is only needed when certain conditions are met + /// within the [State], [registerForRestoration] may also be called at any + /// time after [restoreState] has been invoked for the first time. + /// + /// A property that has been registered outside of [restoreState] must be + /// re-registered within [restoreState] the next time that method is called + /// unless it has been unregistered with [unregisterFromRestoration]. + @protected + void registerForRestoration(RestorableProperty property, String restorationId) { + assert(property != null); + assert(restorationId != null); + assert(property._restorationId == null || (_debugDoingRestore && property._restorationId == restorationId), + 'Property is already registered under ${property._restorationId}.', + ); + assert(_debugDoingRestore || !_properties.keys.map((RestorableProperty r) => r._restorationId).contains(restorationId), + '"$restorationId" is already registered to another property.' + ); + final bool hasSerializedValue = bucket?.contains(restorationId) == true; + final Object initialValue = hasSerializedValue + ? property.fromPrimitives(bucket.read(restorationId)) + : property.createDefaultValue(); + + if (!property.isRegistered) { + property._register(restorationId, this); + final VoidCallback listener = () { + if (bucket == null) + return; + _updateProperty(property); + }; + property.addListener(listener); + _properties[property] = listener; + } + + assert( + property._restorationId == restorationId && + property._owner == this && + _properties.containsKey(property) + ); + + property.initWithValue(initialValue); + if (!hasSerializedValue && property.enabled && bucket != null) { + _updateProperty(property); + } + + assert(() { + _debugPropertiesWaitingForReregistration?.remove(property); + return true; + }()); + } + + /// Unregisters a [RestorableProperty] from state restoration. + /// + /// The value of the `property` is removed from the restoration data and it + /// will not be restored if that data is used in a future state restoration. + /// + /// Calling this method is uncommon, but may be necessary if the data of a + /// [RestorableProperty] is only relevant when the [State] object is in a + /// certain state. When the data of a property is no longer necessary to + /// restore the internal state of a [State] object, it may be removed from the + /// restoration data by calling this method. + @protected + void unregisterFromRestoration(RestorableProperty property) { + assert(property != null); + assert(property._owner == this); + _bucket?.remove(property._restorationId); + _unregister(property); + } + + /// Must be called when the value returned by [restorationId] changes. + /// + /// This method is automatically called from [didUpdateWidget]. Therefore, + /// manually invoking this method may be omitted when the change in + /// [restorationId] was caused by an updated widget. + @protected + void didUpdateRestorationId() { + if (_bucket?.restorationId != restorationId && !restorePending) { + _updateBucketIfNecessary(); + } + } + + @override + void didUpdateWidget(S oldWidget) { + super.didUpdateWidget(oldWidget); + didUpdateRestorationId(); + } + + /// Whether [restoreState] will be called at the beginning of the next build + /// phase. + /// + /// Returns true when new restoration data has been provided to the mixin, but + /// the registered [RestorableProperty]s have not been restored to their new + /// values (as described by the new restoration data) yet. The properties will + /// get the values restored when [restoreState] is invoked at the beginning of + /// the next build cycle. + /// + /// While this is true, [bucket] will also still return the old bucket with + /// the old restoration data. It will update to the new bucket with the new + /// data just before [restoreState] is invoked. + bool get restorePending => _restorePending; + bool _restorePending = true; + + List> _debugPropertiesWaitingForReregistration; + bool get _debugDoingRestore => _debugPropertiesWaitingForReregistration != null; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + RestorationBucket oldBucket; + if (_restorePending) { + oldBucket = _bucket; + // Throw away the old bucket so [_updateBucketIfNecessary] will claim a + // new one with the new restoration data. + _bucket = null; + } + _updateBucketIfNecessary(); + if (_restorePending) { + _restorePending = false; + + assert(() { + _debugPropertiesWaitingForReregistration = _properties.keys.toList(); + return true; + }()); + + restoreState(oldBucket); + + assert(() { + if (_debugPropertiesWaitingForReregistration.isNotEmpty) { + throw FlutterError.fromParts([ + ErrorSummary( + 'Previously registered RestorableProperties must be re-registered in "restoreState".', + ), + ErrorDescription( + 'The RestorableProperties with the following IDs were not re-registered to $this when ' + '"restoreState" was called:', + ), + ..._debugPropertiesWaitingForReregistration.map((RestorableProperty property) => ErrorDescription( + ' * ${property._restorationId}', + )), + ]); + } + _debugPropertiesWaitingForReregistration = null; + return true; + }()); + + oldBucket?.dispose(); + } + } + + void _markNeedsRestore() { + _restorePending = true; + // [didChangeDependencies] will be called next because our bucket can only + // become invalid if our parent bucket ([RestorationScope.of]) is replaced + // with a new one. + } + + void _updateBucketIfNecessary() { + if (restorationId == null) { + _setNewBucketIfNecessary(newBucket: null); + assert(_bucket == null); + return; + } + final RestorationBucket newParent = RestorationScope.of(context); + if (newParent == null) { + _setNewBucketIfNecessary(newBucket: null); + assert(_bucket == null); + return; + } + if (_bucket == null) { + assert(newParent != null); + assert(restorationId != null); + final RestorationBucket newBucket = newParent.claimChild(restorationId, debugOwner: this) + ..addListener(_markNeedsRestore); + assert(newBucket != null); + _setNewBucketIfNecessary(newBucket: newBucket); + assert(_bucket == newBucket); + return; + } + // We have an existing bucket, make sure it has the right parent and id. + assert(_bucket != null); + assert(newParent != null); + assert(restorationId != null); + _bucket.rename(restorationId); + newParent.adoptChild(_bucket); + } + + void _setNewBucketIfNecessary({@required RestorationBucket newBucket}) { + if (newBucket == _bucket) { + return; + } + assert(newBucket == null || _bucket == null); + final RestorationBucket oldBucket = _bucket; + _bucket = newBucket; + if (!restorePending) { + // Write the current property values into the new bucket to persist them. + if (_bucket != null) { + _properties.keys.forEach(_updateProperty); + } + didToggleBucket(oldBucket); + } + oldBucket?.dispose(); + } + + void _updateProperty(RestorableProperty property) { + if (property.enabled) { + _bucket?.write(property._restorationId, property.toPrimitives()); + } else { + _bucket?.remove(property._restorationId); + } + } + + void _unregister(RestorableProperty property) { + final VoidCallback listener = _properties.remove(property); + assert(() { + _debugPropertiesWaitingForReregistration?.remove(property); + return true; + }()); + property.removeListener(listener); + property._unregister(); + } + + @override + void dispose() { + _properties.forEach((RestorableProperty property, VoidCallback listener) { + if (!property._disposed) { + property.removeListener(listener); + } + }); + _bucket?.dispose(); + _bucket = null; + super.dispose(); + } +} diff --git a/packages/flutter/lib/src/widgets/restoration_properties.dart b/packages/flutter/lib/src/widgets/restoration_properties.dart new file mode 100644 index 00000000000..90a37dcb2e1 --- /dev/null +++ b/packages/flutter/lib/src/widgets/restoration_properties.dart @@ -0,0 +1,267 @@ +// Copyright 2014 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. + +// @dart = 2.8 + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import 'editable_text.dart'; +import 'restoration.dart'; + +/// A [RestorableProperty] that makes the wrapped value accessible to the owning +/// [State] object via the [value] getter and setter. +/// +/// Whenever a new [value] is set, [didUpdateValue] is called. Subclasses should +/// call [notifyListeners] from this method if the new value changes what +/// [toPrimitives] returns. +/// +/// See also: +/// +/// * [RestorableProperty], which is the super class of this class. +/// * [RestorationMixin], to which a [RestorableValue] needs to be registered +/// in order to work. +/// * [RestorationManager], which provides an overview of how state restoration +/// works in Flutter. +abstract class RestorableValue extends RestorableProperty { + /// The current value stored in this property. + /// + /// A representation of the current value is stored in the restoration data. + /// During state restoration, the property will restore the value to what it + /// was when the restoration data it is getting restored from was collected. + /// + /// The [value] can only be accessed after the property has been registered + /// with a [RestorationMixin] by calling + /// [RestorationMixin.registerForRestoration]. + T get value { + assert(isRegistered); + return _value; + } + T _value; + set value(T newValue) { + assert(isRegistered); + if (newValue != _value) { + final T oldValue = _value; + _value = newValue; + didUpdateValue(oldValue); + } + } + + @mustCallSuper + @override + void initWithValue(T value) { + _value = value; + } + + /// Called whenever a new value is assigned to [value]. + /// + /// The new value can be accessed via the regular [value] getter and the + /// previous value is provided as `oldValue`. + /// + /// Subclasses should call [notifyListeners] from this method, if the new + /// value changes what [toPrimitives] returns. + @protected + void didUpdateValue(T oldValue); +} + +// _RestorablePrimitiveValue and its subclasses do not allow null values in +// anticipation of NNBD (non-nullability by default). +// +// If necessary, we can in the future define a new subclass hierarchy that +// does allow null values for primitive types. Borrowing from lisp where +// functions that returned a bool ended in 'p', a suggested naming scheme for +// these new subclasses could be to add 'N' (for nullable) to the end of a +// class name (e.g. RestorableIntN, RestorableStringN, etc.) to distinguish them +// from their non-nullable friends. +class _RestorablePrimitiveValue extends RestorableValue { + _RestorablePrimitiveValue(this._defaultValue) + : assert(_defaultValue != null), + assert(debugIsSerializableForRestoration(_defaultValue)), + super(); + + final T _defaultValue; + + @override + T createDefaultValue() => _defaultValue; + + @override + set value(T value) { + assert(value != null); + super.value = value; + } + + @override + void didUpdateValue(T oldValue) { + assert(debugIsSerializableForRestoration(value)); + notifyListeners(); + } + + @override + T fromPrimitives(Object serialized) { + assert(serialized != null); + return serialized as T; + } + + @override + Object toPrimitives() { + assert(value != null); + return value; + } +} + +/// A [RestorableProperty] that knows how to store and restore a [num]. +/// +/// {@template flutter.widgets.restoration.primitivevalue} +/// The current [value] of this property is stored in the restoration data. +/// During state restoration the property is restored to the value it had when +/// the restoration data it is getting restored from was collected. +/// +/// If no restoration data is available, [value] is initialized to the +/// `defaultValue` given in the constructor. +/// {@endtemplate} +/// +/// Instead of using the more generic [RestorableNum] directly, consider using +/// one of the more specific subclasses (e.g. [RestorableDouble] to store a +/// [double] and [RestorableInt] to store an [int]). +class RestorableNum extends _RestorablePrimitiveValue { + /// Creates a [RestorableNum]. + /// + /// {@template flutter.widgets.restoration.primitivevalue.constructor} + /// If no restoration data is available to restore the value in this property + /// from, the property will be initialized with the provided `defaultValue`. + /// {@endtemplate} + RestorableNum(T defaultValue) : assert(defaultValue != null), super(defaultValue); +} + +/// A [RestorableProperty] that knows how to store and restore a [double]. +/// +/// {@macro flutter.widgets.restoration.primitivevalue} +class RestorableDouble extends RestorableNum { + /// Creates a [RestorableDouble]. + /// + /// {@macro flutter.widgets.restoration.primitivevalue.constructor} + RestorableDouble(double defaultValue) : assert(defaultValue != null), super(defaultValue); +} + +/// A [RestorableProperty] that knows how to store and restore an [int]. +/// +/// {@macro flutter.widgets.restoration.primitivevalue} +class RestorableInt extends RestorableNum { + /// Creates a [RestorableInt]. + /// + /// {@macro flutter.widgets.restoration.primitivevalue.constructor} + RestorableInt(int defaultValue) : assert(defaultValue != null), super(defaultValue); +} + +/// A [RestorableProperty] that knows how to store and restore a [String]. +/// +/// {@macro flutter.widgets.restoration.primitivevalue} +class RestorableString extends _RestorablePrimitiveValue { + /// Creates a [RestorableString]. + /// + /// {@macro flutter.widgets.restoration.primitivevalue.constructor} + RestorableString(String defaultValue) : assert(defaultValue != null), super(defaultValue); +} + +/// A [RestorableProperty] that knows how to store and restore a [bool]. +/// +/// {@macro flutter.widgets.restoration.primitivevalue} +class RestorableBool extends _RestorablePrimitiveValue { + /// Creates a [RestorableBool]. + /// + /// {@macro flutter.widgets.restoration.primitivevalue.constructor} + RestorableBool(bool defaultValue) : assert(defaultValue != null), super(defaultValue); +} + +/// A base class for creating a [RestorableProperty] that stores and restores a +/// [Listenable]. +/// +/// This class may be used to implement a [RestorableProperty] for a +/// [Listenable], whose information it needs to store in the restoration data +/// change whenever the [Listenable] notifies its listeners. +/// +/// The [RestorationMixin] this property is registered with will call +/// [toPrimitives] whenever the wrapped [Listenable] notifies its listeners to +/// update the information that this property has stored in the restoration +/// data. +abstract class RestorableListenable extends RestorableProperty { + /// The [Listenable] stored in this property. + /// + /// A representation of the current value of the [Listenable] is stored in the + /// restoration data. During state restoration, the [Listenable] returned by + /// this getter will be restored to the state it had when the restoration data + /// the property is getting restored from was collected. + /// + /// The [value] can only be accessed after the property has been registered + /// with a [RestorationMixin] by calling + /// [RestorationMixin.registerForRestoration]. + T get value { + assert(isRegistered); + return _value; + } + T _value; + + @override + void initWithValue(T value) { + assert(value != null); + _value?.removeListener(notifyListeners); + _value = value; + _value.addListener(notifyListeners); + } + + @override + void dispose() { + super.dispose(); + _value?.removeListener(notifyListeners); + } +} + +/// A [RestorableProperty] that knows how to store and restore a +/// [TextEditingController]. +/// +/// The [TextEditingController] is accessible via the [value] getter. During +/// state restoration, the property will restore [TextEditingController.text] to +/// the value it had when the restoration data it is getting restored from was +/// collected. +class RestorableTextEditingController extends RestorableListenable { + /// Creates a [RestorableTextEditingController]. + /// + /// This constructor treats a null `text` argument as if it were the empty + /// string. + factory RestorableTextEditingController({String text}) => RestorableTextEditingController.fromValue( + text == null ? TextEditingValue.empty : TextEditingValue(text: text), + ); + + /// Creates a [RestorableTextEditingController] from an initial + /// [TextEditingValue]. + /// + /// This constructor treats a null `value` argument as if it were + /// [TextEditingValue.empty]. + RestorableTextEditingController.fromValue(TextEditingValue value) : _initialValue = value; + + final TextEditingValue _initialValue; + + @override + TextEditingController createDefaultValue() { + return TextEditingController.fromValue(_initialValue); + } + + @override + TextEditingController fromPrimitives(Object data) { + return TextEditingController(text: data as String); + } + + @override + Object toPrimitives() { + return value.text; + } + + @override + void dispose() { + if (isRegistered) { + value.dispose(); + } + super.dispose(); + } +} diff --git a/packages/flutter/lib/widgets.dart b/packages/flutter/lib/widgets.dart index 36fc499652f..9d5e8bb2e2e 100644 --- a/packages/flutter/lib/widgets.dart +++ b/packages/flutter/lib/widgets.dart @@ -82,6 +82,8 @@ export 'src/widgets/platform_view.dart'; export 'src/widgets/preferred_size.dart'; export 'src/widgets/primary_scroll_controller.dart'; export 'src/widgets/raw_keyboard_listener.dart'; +export 'src/widgets/restoration.dart'; +export 'src/widgets/restoration_properties.dart'; export 'src/widgets/routes.dart'; export 'src/widgets/safe_area.dart'; export 'src/widgets/scroll_activity.dart'; diff --git a/packages/flutter/test/services/restoration.dart b/packages/flutter/test/services/restoration.dart new file mode 100644 index 00000000000..afe05d4efc3 --- /dev/null +++ b/packages/flutter/test/services/restoration.dart @@ -0,0 +1,67 @@ +// Copyright 2014 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. + +// @dart = 2.8 + +import 'dart:typed_data'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class MockRestorationManager extends TestRestorationManager { + bool get updateScheduled => _updateScheduled; + bool _updateScheduled = false; + + final List _buckets = []; + + @override + void scheduleSerializationFor(RestorationBucket bucket) { + _updateScheduled = true; + _buckets.add(bucket); + } + + @override + bool unscheduleSerializationFor(RestorationBucket bucket) { + _updateScheduled = true; + return _buckets.remove(bucket); + } + + void doSerialization() { + _updateScheduled = false; + for (final RestorationBucket bucket in _buckets) { + bucket.finalize(); + } + _buckets.clear(); + } + + @override + void restoreFrom(TestRestorationData data) { + // Ignore in mock. + } + + int rootBucketAccessed = 0; + + @override + Future get rootBucket { + rootBucketAccessed++; + return _rootBucket; + } + Future _rootBucket; + set rootBucket(Future value) { + _rootBucket = value; + notifyListeners(); + } + + + @override + Future sendToEngine(Uint8List encodedData) { + throw UnimplementedError('unimplemented in mock'); + } + + @override + String toString() => 'MockManager'; +} + +const String childrenMapKey = 'c'; +const String valuesMapKey = 'v'; diff --git a/packages/flutter/test/services/restoration_bucket_test.dart b/packages/flutter/test/services/restoration_bucket_test.dart new file mode 100644 index 00000000000..d0bf2d53ce6 --- /dev/null +++ b/packages/flutter/test/services/restoration_bucket_test.dart @@ -0,0 +1,629 @@ +// Copyright 2014 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. + +// @dart = 2.8 + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'restoration.dart'; + +void main() { + test('root bucket values', () { + final MockRestorationManager manager = MockRestorationManager(); + final Map rawData = _createRawDataSet(); + final RestorationBucket bucket = RestorationBucket.root(manager: manager, rawData: rawData); + + expect(bucket.restorationId, 'root'); + expect(bucket.debugOwner, manager); + + // Bucket contains expected values from rawData. + expect(bucket.read('value1'), 10); + expect(bucket.read('value2'), 'Hello'); + expect(bucket.read('value3'), isNull); // Does not exist. + expect(manager.updateScheduled, isFalse); + + // Can overwrite existing value. + bucket.write('value1', 22); + expect(manager.updateScheduled, isTrue); + expect(bucket.read('value1'), 22); + manager.doSerialization(); + expect(rawData[valuesMapKey]['value1'], 22); + expect(manager.updateScheduled, isFalse); + + // Can add a new value. + bucket.write('value3', true); + expect(manager.updateScheduled, isTrue); + expect(bucket.read('value3'), true); + manager.doSerialization(); + expect(rawData[valuesMapKey]['value3'], true); + expect(manager.updateScheduled, isFalse); + + // Can remove existing value. + expect(bucket.remove('value1'), 22); + expect(manager.updateScheduled, isTrue); + expect(bucket.read('value1'), isNull); // Does not exist anymore. + manager.doSerialization(); + expect(rawData[valuesMapKey].containsKey('value1'), isFalse); + expect(manager.updateScheduled, isFalse); + + // Removing non-existing value is no-op. + expect(bucket.remove('value4'), isNull); + expect(manager.updateScheduled, isFalse); + + // Can store null. + bucket.write('value4', null); + expect(manager.updateScheduled, isTrue); + expect(bucket.read('value4'), null); + manager.doSerialization(); + expect(rawData[valuesMapKey].containsKey('value4'), isTrue); + expect(rawData[valuesMapKey]['value4'], null); + expect(manager.updateScheduled, isFalse); + }); + + test('child bucket values', () { + final MockRestorationManager manager = MockRestorationManager(); + final Map rootRawData = _createRawDataSet(); + final Object debugOwner = Object(); + final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: rootRawData); + final RestorationBucket child = RestorationBucket.child( + restorationId: 'child1', + parent: root, + debugOwner: debugOwner, + ); + + expect(child.restorationId, 'child1'); + expect(child.debugOwner, debugOwner); + + // Bucket contains expected values from rawData. + expect(child.read('foo'), 22); + expect(child.read('bar'), isNull); // Does not exist. + expect(manager.updateScheduled, isFalse); + + // Can overwrite existing value. + child.write('foo', 44); + expect(manager.updateScheduled, isTrue); + expect(child.read('foo'), 44); + manager.doSerialization(); + expect(rootRawData[childrenMapKey]['child1'][valuesMapKey]['foo'], 44); + expect(manager.updateScheduled, isFalse); + + // Can add a new value. + child.write('value3', true); + expect(manager.updateScheduled, isTrue); + expect(child.read('value3'), true); + manager.doSerialization(); + expect(rootRawData[childrenMapKey]['child1'][valuesMapKey]['value3'], true); + expect(manager.updateScheduled, isFalse); + + // Can remove existing value. + expect(child.remove('foo'), 44); + expect(manager.updateScheduled, isTrue); + expect(child.read('foo'), isNull); // Does not exist anymore. + manager.doSerialization(); + expect(rootRawData[childrenMapKey]['child1'].containsKey('foo'), isFalse); + expect(manager.updateScheduled, isFalse); + + // Removing non-existing value is no-op. + expect(child.remove('value4'), isNull); + expect(manager.updateScheduled, isFalse); + + // Can store null. + child.write('value4', null); + expect(manager.updateScheduled, isTrue); + expect(child.read('value4'), null); + manager.doSerialization(); + expect(rootRawData[childrenMapKey]['child1'][valuesMapKey].containsKey('value4'), isTrue); + expect(rootRawData[childrenMapKey]['child1'][valuesMapKey]['value4'], null); + expect(manager.updateScheduled, isFalse); + }); + + test('claim child with exisiting data', () { + final MockRestorationManager manager = MockRestorationManager(); + final Map rawData = _createRawDataSet(); + final RestorationBucket bucket = RestorationBucket.root(manager: manager, rawData: rawData); + + final Object debugOwner = Object(); + final RestorationBucket child = bucket.claimChild('child1', debugOwner: debugOwner); + + expect(manager.updateScheduled, isFalse); + expect(child.restorationId, 'child1'); + expect(child.debugOwner, debugOwner); + + expect(child.read('foo'), 22); + child.write('bar', 44); + expect(manager.updateScheduled, isTrue); + manager.doSerialization(); + expect(rawData[childrenMapKey]['child1'][valuesMapKey]['bar'], 44); + expect(manager.updateScheduled, isFalse); + }); + + test('claim child with no exisiting data', () { + final MockRestorationManager manager = MockRestorationManager(); + final Map rawData = _createRawDataSet(); + final RestorationBucket bucket = RestorationBucket.root(manager: manager, rawData: rawData); + + expect(rawData[childrenMapKey].containsKey('child2'), isFalse); + + final Object debugOwner = Object(); + final RestorationBucket child = bucket.claimChild('child2', debugOwner: debugOwner); + + expect(manager.updateScheduled, isTrue); + expect(child.restorationId, 'child2'); + expect(child.debugOwner, debugOwner); + + child.write('foo', 55); + expect(child.read('foo'), 55); + manager.doSerialization(); + + expect(manager.updateScheduled, isFalse); + expect(rawData[childrenMapKey].containsKey('child2'), isTrue); + expect(rawData[childrenMapKey]['child2'][valuesMapKey]['foo'], 55); + }); + + test('claim child that is already claimed throws if not given up', () { + final MockRestorationManager manager = MockRestorationManager(); + final Map rawData = _createRawDataSet(); + final RestorationBucket bucket = RestorationBucket.root(manager: manager, rawData: rawData); + + final RestorationBucket child1 = bucket.claimChild('child1', debugOwner: 'FirstClaim'); + + expect(manager.updateScheduled, isFalse); + expect(child1.restorationId, 'child1'); + expect(child1.read('foo'), 22); + + manager.doSerialization(); + expect(manager.updateScheduled, isFalse); + + final RestorationBucket child2 = bucket.claimChild('child1', debugOwner: 'SecondClaim'); + expect(child2.restorationId, 'child1'); + expect(child2.read('foo'), isNull); // Value does not exist in this child. + + // child1 is not given up before running finalizers. + try { + manager.doSerialization(); + fail('expected error'); + } on FlutterError catch (e) { + expect( + e.message, + 'Multiple owners claimed child RestorationBuckets with the same IDs.\n' + 'The following IDs were claimed multiple times from the parent RestorationBucket(restorationId: root, owner: MockManager):\n' + ' * "child1" was claimed by:\n' + ' * SecondClaim\n' + ' * FirstClaim (current owner)' + ); + } + }); + + test('claim child that is already claimed does not throw if given up', () { + final MockRestorationManager manager = MockRestorationManager(); + final Map rawData = _createRawDataSet(); + final RestorationBucket bucket = RestorationBucket.root(manager: manager, rawData: rawData); + + final RestorationBucket child1 = bucket.claimChild('child1', debugOwner: 'FirstClaim'); + + expect(manager.updateScheduled, isFalse); + expect(child1.restorationId, 'child1'); + expect(child1.read('foo'), 22); + + manager.doSerialization(); + expect(manager.updateScheduled, isFalse); + + final RestorationBucket child2 = bucket.claimChild('child1', debugOwner: 'SecondClaim'); + expect(child2.restorationId, 'child1'); + expect(child2.read('foo'), isNull); // Value does not exist in this child. + child2.write('bar', 55); + + // give up child1. + child1.dispose(); + manager.doSerialization(); + expect(manager.updateScheduled, isFalse); + expect(rawData[childrenMapKey]['child1'][valuesMapKey].containsKey('foo'), isFalse); + expect(rawData[childrenMapKey]['child1'][valuesMapKey]['bar'], 55); + }); + + test('claiming a claimed child twice and only giving it up once throws', () { + final MockRestorationManager manager = MockRestorationManager(); + final Map rawData = _createRawDataSet(); + final RestorationBucket bucket = RestorationBucket.root(manager: manager, rawData: rawData); + + final RestorationBucket child1 = bucket.claimChild('child1', debugOwner: 'FirstClaim'); + expect(child1.restorationId, 'child1'); + final RestorationBucket child2 = bucket.claimChild('child1', debugOwner: 'SecondClaim'); + expect(child2.restorationId, 'child1'); + child1.dispose(); + final RestorationBucket child3 = bucket.claimChild('child1', debugOwner: 'ThirdClaim'); + expect(child3.restorationId, 'child1'); + expect(manager.updateScheduled, isTrue); + expect(() => manager.doSerialization(), throwsFlutterError); + }); + + test('unclaiming and then claiming same id gives fresh bucket', () { + final MockRestorationManager manager = MockRestorationManager(); + final Map rawData = _createRawDataSet(); + final RestorationBucket bucket = RestorationBucket.root(manager: manager, rawData: rawData); + + final RestorationBucket child1 = bucket.claimChild('child1', debugOwner: 'FirstClaim'); + expect(manager.updateScheduled, isFalse); + expect(child1.read('foo'), 22); + child1.dispose(); + expect(manager.updateScheduled, isTrue); + final RestorationBucket child2 = bucket.claimChild('child1', debugOwner: 'SecondClaim'); + expect(child2.read('foo'), isNull); + }); + + test('cleans up raw data if last value/child is dropped', () { + final MockRestorationManager manager = MockRestorationManager(); + final Map rawData = _createRawDataSet(); + final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: rawData); + + expect(rawData.containsKey(childrenMapKey), isTrue); + final RestorationBucket child = root.claimChild('child1', debugOwner: 'owner'); + child.dispose(); + expect(manager.updateScheduled, isTrue); + manager.doSerialization(); + expect(rawData.containsKey(childrenMapKey), isFalse); + + expect(rawData.containsKey(valuesMapKey), isTrue); + expect(root.remove('value1'), 10); + expect(root.remove('value2'), 'Hello'); + expect(manager.updateScheduled, isTrue); + manager.doSerialization(); + expect(rawData.containsKey(valuesMapKey), isFalse); + }); + + test('dispose deletes data', () { + final MockRestorationManager manager = MockRestorationManager(); + final Map rawData = _createRawDataSet(); + final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: rawData); + + final RestorationBucket child1 = root.claimChild('child1', debugOwner: 'owner1'); + child1.claimChild('child1OfChild1', debugOwner: 'owner1.1'); + child1.claimChild('child2OfChild1', debugOwner: 'owner1.2'); + final RestorationBucket child2 = root.claimChild('child2', debugOwner: 'owner2'); + + expect(manager.updateScheduled, isTrue); + manager.doSerialization(); + expect(manager.updateScheduled, isFalse); + + expect(rawData[childrenMapKey].containsKey('child1'), isTrue); + expect(rawData[childrenMapKey].containsKey('child2'), isTrue); + + child1.dispose(); + expect(manager.updateScheduled, isTrue); + manager.doSerialization(); + expect(manager.updateScheduled, isFalse); + + expect(rawData[childrenMapKey].containsKey('child1'), isFalse); + + child2.dispose(); + expect(manager.updateScheduled, isTrue); + manager.doSerialization(); + expect(manager.updateScheduled, isFalse); + + expect(rawData.containsKey(childrenMapKey), isFalse); + }); + + test('rename is no-op if same id', () { + final MockRestorationManager manager = MockRestorationManager(); + final Map rawData = _createRawDataSet(); + final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: rawData); + + final RestorationBucket child = root.claimChild('child1', debugOwner: 'owner1'); + + expect(manager.updateScheduled, isFalse); + expect(child.restorationId, 'child1'); + child.rename('child1'); + expect(manager.updateScheduled, isFalse); + expect(child.restorationId, 'child1'); + expect(rawData[childrenMapKey].containsKey('child1'), isTrue); + }); + + test('rename to unused id', () { + final MockRestorationManager manager = MockRestorationManager(); + final Map rawData = _createRawDataSet(); + final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: rawData); + + final RestorationBucket child = root.claimChild('child1', debugOwner: 'owner1'); + final Object rawChildData = rawData[childrenMapKey]['child1']; + expect(rawChildData, isNotNull); + + expect(manager.updateScheduled, isFalse); + expect(child.restorationId, 'child1'); + child.rename('new-name'); + expect(child.restorationId, 'new-name'); + + expect(manager.updateScheduled, isTrue); + manager.doSerialization(); + expect(manager.updateScheduled, isFalse); + + expect(rawData[childrenMapKey].containsKey('child1'), isFalse); + expect(rawData[childrenMapKey]['new-name'], rawChildData); + }); + + test('rename to used id throws if id is not given up', () { + final MockRestorationManager manager = MockRestorationManager(); + final Map rawData = _createRawDataSet(); + final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: rawData); + + final RestorationBucket child1 = root.claimChild('child1', debugOwner: 'owner1'); + final RestorationBucket child2 = root.claimChild('child2', debugOwner: 'owner1'); + manager.doSerialization(); + + expect(child1.restorationId, 'child1'); + expect(child2.restorationId, 'child2'); + child2.rename('child1'); + expect(child2.restorationId, 'child1'); + + expect(manager.updateScheduled, isTrue); + expect(() => manager.doSerialization(), throwsFlutterError); + }); + + test('rename to used id does not throw if id is given up', () { + final MockRestorationManager manager = MockRestorationManager(); + final Map rawData = _createRawDataSet(); + final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: rawData); + + final RestorationBucket child1 = root.claimChild('child1', debugOwner: 'owner1'); + final RestorationBucket child2 = root.claimChild('child2', debugOwner: 'owner1'); + manager.doSerialization(); + + final Object rawChild1Data = rawData[childrenMapKey]['child1']; + expect(rawChild1Data, isNotNull); + final Object rawChild2Data = rawData[childrenMapKey]['child2']; + expect(rawChild2Data, isNotNull); + + expect(child1.restorationId, 'child1'); + expect(child2.restorationId, 'child2'); + child2.rename('child1'); + expect(child2.restorationId, 'child1'); + expect(child1.restorationId, 'child1'); + + child1.dispose(); + + expect(manager.updateScheduled, isTrue); + manager.doSerialization(); + expect(manager.updateScheduled, isFalse); + + expect(rawData[childrenMapKey]['child1'], rawChild2Data); + expect(rawData[childrenMapKey].containsKey('child2'), isFalse); + }); + + test('renaming a to be added child', () { + final MockRestorationManager manager = MockRestorationManager(); + final Map rawData = _createRawDataSet(); + final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: rawData); + + final Object rawChild1Data = rawData[childrenMapKey]['child1']; + expect(rawChild1Data, isNotNull); + + final RestorationBucket child1 = root.claimChild('child1', debugOwner: 'owner1'); + final RestorationBucket child2 = root.claimChild('child1', debugOwner: 'owner1'); + + child2.rename('foo'); + + expect(manager.updateScheduled, isTrue); + manager.doSerialization(); + expect(manager.updateScheduled, isFalse); + + expect(child1.restorationId, 'child1'); + expect(child2.restorationId, 'foo'); + + expect(rawData[childrenMapKey]['child1'], rawChild1Data); + expect(rawData[childrenMapKey]['foo'], isEmpty); // new bucket + }); + + test('adopt is no-op if same parent', () { + final MockRestorationManager manager = MockRestorationManager(); + final Map rawData = _createRawDataSet(); + final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: rawData); + + final RestorationBucket child1 = root.claimChild('child1', debugOwner: 'owner1'); + + root.adoptChild(child1); + expect(manager.updateScheduled, isFalse); + expect(rawData[childrenMapKey].containsKey('child1'), isTrue); + }); + + test('adopt fresh child', () { + final MockRestorationManager manager = MockRestorationManager(); + final Map rawData = _createRawDataSet(); + final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: rawData); + + final RestorationBucket child = RestorationBucket.empty(restorationId: 'fresh-child', debugOwner: 'owner1'); + + root.adoptChild(child); + expect(manager.updateScheduled, isTrue); + + child.write('value', 22); + + manager.doSerialization(); + expect(manager.updateScheduled, isFalse); + + expect(rawData[childrenMapKey].containsKey('fresh-child'), isTrue); + expect(rawData[childrenMapKey]['fresh-child'][valuesMapKey]['value'], 22); + + child.write('bar', 'blabla'); + expect(manager.updateScheduled, isTrue); + }); + + test('adopt child that already had a parent', () { + final MockRestorationManager manager = MockRestorationManager(); + final Map rawData = _createRawDataSet(); + final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: rawData); + + final RestorationBucket child = root.claimChild('child1', debugOwner: 'owner1'); + final RestorationBucket childOfChild = child.claimChild('childOfChild', debugOwner: 'owner2'); + childOfChild.write('foo', 'bar'); + + expect(manager.updateScheduled, isTrue); + manager.doSerialization(); + expect(manager.updateScheduled, isFalse); + + final Object childOfChildData = rawData[childrenMapKey]['child1'][childrenMapKey]['childOfChild']; + expect(childOfChildData, isNotEmpty); + + root.adoptChild(childOfChild); + expect(manager.updateScheduled, isTrue); + manager.doSerialization(); + expect(manager.updateScheduled, isFalse); + + expect(rawData[childrenMapKey]['child1'].containsKey(childrenMapKey), isFalse); // child1 has no children anymore. + expect(rawData[childrenMapKey]['childOfChild'], childOfChildData); + }); + + test('adopting child throws if id is already in use and not given up', () { + final MockRestorationManager manager = MockRestorationManager(); + final Map rawData = _createRawDataSet(); + final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: rawData); + + final RestorationBucket child = root.claimChild('child1', debugOwner: 'owner1'); + final RestorationBucket childOfChild = child.claimChild('child1', debugOwner: 'owner2'); + childOfChild.write('foo', 'bar'); + + root.adoptChild(childOfChild); + expect(manager.updateScheduled, isTrue); + expect(() => manager.doSerialization(), throwsFlutterError); + }); + + test('adopting child does not throw if id is already in use and given up', () { + final MockRestorationManager manager = MockRestorationManager(); + final Map rawData = _createRawDataSet(); + final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: rawData); + + final RestorationBucket child = root.claimChild('child1', debugOwner: 'owner1'); + final RestorationBucket childOfChild = child.claimChild('child1', debugOwner: 'owner2'); + childOfChild.write('foo', 'bar'); + + final Object childOfChildData = rawData[childrenMapKey]['child1'][childrenMapKey]['child1']; + expect(childOfChildData, isNotEmpty); + + expect(manager.updateScheduled, isTrue); + manager.doSerialization(); + expect(manager.updateScheduled, isFalse); + + root.adoptChild(childOfChild); + expect(manager.updateScheduled, isTrue); + child.dispose(); + manager.doSerialization(); + expect(manager.updateScheduled, isFalse); + + expect(rawData[childrenMapKey]['child1'], childOfChildData); + }); + + test('adopting a to-be-added child under an already in use id', () { + final MockRestorationManager manager = MockRestorationManager(); + final Map rawData = _createRawDataSet(); + final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: rawData); + + final RestorationBucket child1 = root.claimChild('child1', debugOwner: 'owner1'); + final RestorationBucket child2 = root.claimChild('child2', debugOwner: 'owner1'); + + expect(manager.updateScheduled, isTrue); + manager.doSerialization(); + expect(manager.updateScheduled, isFalse); + + final RestorationBucket child1OfChild1 = child1.claimChild('child2', debugOwner: 'owner2'); + child1OfChild1.write('hello', 'world'); + final RestorationBucket child2OfChild1 = child1.claimChild('child2', debugOwner: 'owner2'); + child2OfChild1.write('foo', 'bar'); + + root.adoptChild(child2OfChild1); + child2.dispose(); + + expect(manager.updateScheduled, isTrue); + manager.doSerialization(); + expect(manager.updateScheduled, isFalse); + + expect(rawData[childrenMapKey]['child2'][valuesMapKey]['foo'], 'bar'); + expect(rawData[childrenMapKey]['child1'][childrenMapKey]['child2'][valuesMapKey]['hello'], 'world'); + }); + + test('decommission drops itself from parent and notifies all listeners', () { + final MockRestorationManager manager = MockRestorationManager(); + final Map rawData = _createRawDataSet(); + final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: rawData); + + final RestorationBucket child1 = root.claimChild('child1', debugOwner: 'owner1'); + final RestorationBucket child2 = root.claimChild('child2', debugOwner: 'owner1'); + final RestorationBucket childOfChild1 = child1.claimChild('child1.1', debugOwner: 'owner1'); + final RestorationBucket childOfChildOfChild1 = childOfChild1.claimChild('child1.1.1', debugOwner: 'owner1'); + + expect(manager.updateScheduled, isTrue); + manager.doSerialization(); + expect(manager.updateScheduled, isFalse); + + bool rootDecommissioned = false; + root.addListener(() { + rootDecommissioned = true; + }); + bool child1Decommissioned = false; + child1.addListener(() { + child1Decommissioned = true; + }); + bool child2Decommissioned = false; + child2.addListener(() { + child2Decommissioned = true; + }); + bool childOfChild1Decommissioned = false; + childOfChild1.addListener(() { + childOfChild1Decommissioned = true; + }); + bool childOfChildOfChild1Decommissioned = false; + childOfChildOfChild1.addListener(() { + childOfChildOfChild1Decommissioned = true; + }); + + expect(rawData[childrenMapKey].containsKey('child1'), isTrue); + + child1.decommission(); + expect(rootDecommissioned, isFalse); + expect(child2Decommissioned, isFalse); + expect(child1Decommissioned, isTrue); + expect(childOfChild1Decommissioned, isTrue); + expect(childOfChildOfChild1Decommissioned, isTrue); + + expect(rawData[childrenMapKey].containsKey('child1'), isFalse); + }); + + test('throws when used after dispose', () { + final RestorationBucket bucket = RestorationBucket.empty(restorationId: 'foo', debugOwner: null); + bucket.dispose(); + + expect(() => bucket.debugOwner, throwsFlutterError); + expect(() => bucket.restorationId, throwsFlutterError); + expect(() => bucket.decommission(), throwsFlutterError); + expect(() => bucket.read('foo'), throwsFlutterError); + expect(() => bucket.write('foo', 10), throwsFlutterError); + expect(() => bucket.remove('foo'), throwsFlutterError); + expect(() => bucket.contains('foo'), throwsFlutterError); + expect(() => bucket.claimChild('child', debugOwner: null), throwsFlutterError); + final RestorationBucket child = RestorationBucket.empty(restorationId: 'child', debugOwner: null); + expect(() => bucket.adoptChild(child), throwsFlutterError); + expect(() => bucket.rename('bar'), throwsFlutterError); + expect(() => bucket.dispose(), throwsFlutterError); + }); + + test('cannot serialize without manager', () { + final RestorationBucket bucket = RestorationBucket.empty(restorationId: 'foo', debugOwner: null); + expect(() => bucket.write('foo', 10), throwsAssertionError); + }); +} + +Map _createRawDataSet() { + return { + valuesMapKey: { + 'value1' : 10, + 'value2' : 'Hello', + }, + childrenMapKey: { + 'child1' : { + valuesMapKey : { + 'foo': 22, + }, + }, + }, + }; +} diff --git a/packages/flutter/test/services/restoration_test.dart b/packages/flutter/test/services/restoration_test.dart new file mode 100644 index 00000000000..be487809427 --- /dev/null +++ b/packages/flutter/test/services/restoration_test.dart @@ -0,0 +1,270 @@ +// Copyright 2014 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. + +// @dart = 2.8 + +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'restoration.dart'; + +void main() { + group('RestorationManager', () { + testWidgets('root bucket retrieval', (WidgetTester tester) async { + final List callsToEngine = []; + final Completer> result = Completer>(); + SystemChannels.restoration.setMockMethodCallHandler((MethodCall call) { + callsToEngine.add(call); + return result.future; + }); + + final RestorationManager manager = RestorationManager(); + final Future rootBucketFuture = manager.rootBucket; + RestorationBucket rootBucket; + rootBucketFuture.then((RestorationBucket bucket) { + rootBucket = bucket; + }); + expect(rootBucketFuture, isNotNull); + expect(rootBucket, isNull); + + // Accessing rootBucket again gives same future. + expect(manager.rootBucket, same(rootBucketFuture)); + + // Engine has only been contacted once. + expect(callsToEngine, hasLength(1)); + expect(callsToEngine.single.method, 'get'); + + // Complete the engine request. + result.complete(_createEncodedRestorationData1()); + await tester.pump(); + + // Root bucket future completed. + expect(rootBucket, isNotNull); + + // Root bucket contains the expected data. + expect(rootBucket.read('value1'), 10); + expect(rootBucket.read('value2'), 'Hello'); + final RestorationBucket child = rootBucket.claimChild('child1', debugOwner: null); + expect(child.read('another value'), 22); + + // Accessing the root bucket again completes synchronously with same bucket. + RestorationBucket synchronousBucket; + manager.rootBucket.then((RestorationBucket bucket) { + synchronousBucket = bucket; + }); + expect(synchronousBucket, isNotNull); + expect(synchronousBucket, same(rootBucket)); + }); + + testWidgets('root bucket received from engine before retrieval', (WidgetTester tester) async { + SystemChannels.restoration.setMethodCallHandler(null); + final List callsToEngine = []; + SystemChannels.restoration.setMockMethodCallHandler((MethodCall call) { + callsToEngine.add(call); + return null; + }); + final RestorationManager manager = RestorationManager(); + + await _pushDataFromEngine(_createEncodedRestorationData1()); + + RestorationBucket rootBucket; + manager.rootBucket.then((RestorationBucket bucket) => rootBucket = bucket); + // Root bucket is available synchronously. + expect(rootBucket, isNotNull); + // Engine was never asked. + expect(callsToEngine, isEmpty); + }); + + testWidgets('root bucket received while engine retrieval is pending', (WidgetTester tester) async { + SystemChannels.restoration.setMethodCallHandler(null); + final List callsToEngine = []; + final Completer> result = Completer>(); + SystemChannels.restoration.setMockMethodCallHandler((MethodCall call) { + callsToEngine.add(call); + return result.future; + }); + final RestorationManager manager = RestorationManager(); + + RestorationBucket rootBucket; + manager.rootBucket.then((RestorationBucket bucket) => rootBucket = bucket); + expect(rootBucket, isNull); + expect(callsToEngine.single.method, 'get'); + + await _pushDataFromEngine(_createEncodedRestorationData1()); + expect(rootBucket, isNotNull); + expect(rootBucket.read('value1'), 10); + + result.complete(_createEncodedRestorationData2()); + await tester.pump(); + + RestorationBucket rootBucket2; + manager.rootBucket.then((RestorationBucket bucket) => rootBucket2 = bucket); + expect(rootBucket2, isNotNull); + expect(rootBucket2, same(rootBucket)); + expect(rootBucket2.read('value1'), 10); + expect(rootBucket2.contains('foo'), isFalse); + }); + + testWidgets('root bucket is properly replaced when new data is available', (WidgetTester tester) async { + SystemChannels.restoration.setMockMethodCallHandler((MethodCall call) async { + return _createEncodedRestorationData1(); + }); + final RestorationManager manager = RestorationManager(); + RestorationBucket rootBucket; + manager.rootBucket.then((RestorationBucket bucket) { + rootBucket = bucket; + }); + await tester.pump(); + expect(rootBucket, isNotNull); + expect(rootBucket.read('value1'), 10); + final RestorationBucket child = rootBucket.claimChild('child1', debugOwner: null); + expect(child.read('another value'), 22); + + bool rootDecommissioned = false; + bool childDecommissioned = false; + RestorationBucket newRoot; + rootBucket.addListener(() { + rootDecommissioned = true; + manager.rootBucket.then((RestorationBucket bucket) { + newRoot = bucket; + }); + // The new bucket is available synchronously. + expect(newRoot, isNotNull); + }); + child.addListener(() { + childDecommissioned = true; + }); + + // Send new Data. + await _pushDataFromEngine(_createEncodedRestorationData2()); + + expect(rootDecommissioned, isTrue); + expect(childDecommissioned, isTrue); + expect(newRoot, isNot(same(rootBucket))); + + child.dispose(); + + expect(newRoot.read('foo'), 33); + expect(newRoot.read('value1'), null); + final RestorationBucket newChild = newRoot.claimChild('childFoo', debugOwner: null); + expect(newChild.read('bar'), 'Hello'); + }); + + testWidgets('returns null as root bucket when restoration is disabled', (WidgetTester tester) async { + final List callsToEngine = []; + final Completer> result = Completer>(); + SystemChannels.restoration.setMockMethodCallHandler((MethodCall call) { + callsToEngine.add(call); + return result.future; + }); + int listenerCount = 0; + final RestorationManager manager = RestorationManager()..addListener(() { + listenerCount++; + }); + RestorationBucket rootBucket; + bool rootBucketResolved = false; + manager.rootBucket.then((RestorationBucket bucket) { + rootBucketResolved = true; + rootBucket = bucket; + }); + await tester.pump(); + expect(rootBucketResolved, isFalse); + expect(listenerCount, 0); + + result.complete(_packageRestorationData(enabled: false)); + await tester.pump(); + expect(rootBucketResolved, isTrue); + expect(rootBucket, isNull); + + // Switch to non-null. + await _pushDataFromEngine(_createEncodedRestorationData1()); + expect(listenerCount, 1); + manager.rootBucket.then((RestorationBucket bucket) { + rootBucket = bucket; + }); + expect(rootBucket, isNotNull); + + // Switch to null again. + await _pushDataFromEngine(_packageRestorationData(enabled: false)); + expect(listenerCount, 2); + manager.rootBucket.then((RestorationBucket bucket) { + rootBucket = bucket; + }); + expect(rootBucket, isNull); + }); + }); + + test('debugIsSerializableForRestoration', () { + expect(debugIsSerializableForRestoration(Object()), isFalse); + expect(debugIsSerializableForRestoration(Container()), isFalse); + + expect(debugIsSerializableForRestoration(null), isTrue); + expect(debugIsSerializableForRestoration(147823), isTrue); + expect(debugIsSerializableForRestoration(12.43), isTrue); + expect(debugIsSerializableForRestoration('Hello World'), isTrue); + expect(debugIsSerializableForRestoration([12, 13, 14]), isTrue); + expect(debugIsSerializableForRestoration({'v1' : 10, 'v2' : 23}), isTrue); + expect(debugIsSerializableForRestoration({ + 'hello': [12, 12, 12], + 'world': { + 1: true, + 2: false, + 4: true, + }, + }), isTrue); + }); +} + +Future _pushDataFromEngine(Map data) async { + await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/restoration', + const StandardMethodCodec().encodeMethodCall(MethodCall('push', data)), + (_) { }, + ); +} + +Map _createEncodedRestorationData1() { + final Map data = { + valuesMapKey: { + 'value1' : 10, + 'value2' : 'Hello', + }, + childrenMapKey: { + 'child1' : { + valuesMapKey : { + 'another value': 22, + } + }, + }, + }; + return _packageRestorationData(data: data); +} + +Map _createEncodedRestorationData2() { + final Map data = { + valuesMapKey: { + 'foo' : 33, + }, + childrenMapKey: { + 'childFoo' : { + valuesMapKey : { + 'bar': 'Hello', + } + }, + }, + }; + return _packageRestorationData(data: data); +} + +Map _packageRestorationData({bool enabled = true, Map data}) { + final ByteData encoded = const StandardMessageCodec().encodeMessage(data); + return { + 'enabled': enabled, + 'data': encoded == null ? null : encoded.buffer.asUint8List(encoded.offsetInBytes, encoded.lengthInBytes) + }; +} diff --git a/packages/flutter/test/widgets/restorable_property_test.dart b/packages/flutter/test/widgets/restorable_property_test.dart new file mode 100644 index 00000000000..b9226f3140b --- /dev/null +++ b/packages/flutter/test/widgets/restorable_property_test.dart @@ -0,0 +1,377 @@ +// Copyright 2014 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. + +// @dart = 2.8 + +import 'package:flutter/widgets.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('value is not accessible when not registered', (WidgetTester tester) async { + expect(() => RestorableNum(0).value, throwsAssertionError); + expect(() => RestorableDouble(1.0).value, throwsAssertionError); + expect(() => RestorableInt(1).value, throwsAssertionError); + expect(() => RestorableString('hello').value, throwsAssertionError); + expect(() => RestorableBool(true).value, throwsAssertionError); + expect(() => RestorableTextEditingController().value, throwsAssertionError); + expect(() => _TestRestorableValue().value, throwsAssertionError); + }); + + testWidgets('cannot initialize with null', (WidgetTester tester) async { + expect(() => RestorableNum(null), throwsAssertionError); + expect(() => RestorableDouble(null), throwsAssertionError); + expect(() => RestorableInt(null), throwsAssertionError); + expect(() => RestorableString(null).value, throwsAssertionError); + expect(() => RestorableBool(null).value, throwsAssertionError); + }); + + testWidgets('work when not in restoration scope', (WidgetTester tester) async { + await tester.pumpWidget(const _RestorableWidget()); + + expect(find.text('hello world'), findsOneWidget); + final _RestorableWidgetState state = tester.state(find.byType(_RestorableWidget)); + + // Initialized to default values. + expect(state.numValue.value, 99); + expect(state.doubleValue.value, 123.2); + expect(state.intValue.value, 42); + expect(state.stringValue.value, 'hello world'); + expect(state.boolValue.value, false); + expect(state.controllerValue.value.text, 'FooBar'); + expect(state.objectValue.value, 55); + + // Modify values. + state.setProperties(() { + state.numValue.value = 42.2; + state.doubleValue.value = 441.3; + state.intValue.value = 10; + state.stringValue.value = 'guten tag'; + state.boolValue.value = true; + state.controllerValue.value.text = 'blabla'; + state.objectValue.value = 53; + }); + await tester.pump(); + + expect(state.numValue.value, 42.2); + expect(state.doubleValue.value, 441.3); + expect(state.intValue.value, 10); + expect(state.stringValue.value, 'guten tag'); + expect(state.boolValue.value, true); + expect(state.controllerValue.value.text, 'blabla'); + expect(state.objectValue.value, 53); + expect(find.text('guten tag'), findsOneWidget); + }); + + testWidgets('restart and restore', (WidgetTester tester) async { + await tester.pumpWidget(const RootRestorationScope( + restorationId: 'root-child', + child: _RestorableWidget(), + )); + + expect(find.text('hello world'), findsOneWidget); + _RestorableWidgetState state = tester.state(find.byType(_RestorableWidget)); + + // Initialized to default values. + expect(state.numValue.value, 99); + expect(state.doubleValue.value, 123.2); + expect(state.intValue.value, 42); + expect(state.stringValue.value, 'hello world'); + expect(state.boolValue.value, false); + expect(state.controllerValue.value.text, 'FooBar'); + expect(state.objectValue.value, 55); + + // Modify values. + state.setProperties(() { + state.numValue.value = 42.2; + state.doubleValue.value = 441.3; + state.intValue.value = 10; + state.stringValue.value = 'guten tag'; + state.boolValue.value = true; + state.controllerValue.value.text = 'blabla'; + state.objectValue.value = 53; + }); + await tester.pump(); + + expect(state.numValue.value, 42.2); + expect(state.doubleValue.value, 441.3); + expect(state.intValue.value, 10); + expect(state.stringValue.value, 'guten tag'); + expect(state.boolValue.value, true); + expect(state.controllerValue.value.text, 'blabla'); + expect(state.objectValue.value, 53); + expect(find.text('guten tag'), findsOneWidget); + + // Restores to previous values. + await tester.restartAndRestore(); + final _RestorableWidgetState oldState = state; + state = tester.state(find.byType(_RestorableWidget)); + expect(state, isNot(same(oldState))); + + expect(state.numValue.value, 42.2); + expect(state.doubleValue.value, 441.3); + expect(state.intValue.value, 10); + expect(state.stringValue.value, 'guten tag'); + expect(state.boolValue.value, true); + expect(state.controllerValue.value.text, 'blabla'); + expect(state.objectValue.value, 53); + expect(find.text('guten tag'), findsOneWidget); + }); + + testWidgets('cannot set to null', (WidgetTester tester) async { + await tester.pumpWidget(const RootRestorationScope( + restorationId: 'root-child', + child: _RestorableWidget(), + )); + + expect(find.text('hello world'), findsOneWidget); + final _RestorableWidgetState state = tester.state(find.byType(_RestorableWidget)); + + expect(() => state.numValue.value = null, throwsAssertionError); + expect(() => state.doubleValue.value = null, throwsAssertionError); + expect(() => state.intValue.value = null, throwsAssertionError); + expect(() => state.stringValue.value = null, throwsAssertionError); + expect(() => state.boolValue.value = null, throwsAssertionError); + }); + + testWidgets('restore to older state', (WidgetTester tester) async { + await tester.pumpWidget(const RootRestorationScope( + restorationId: 'root-child', + child: _RestorableWidget(), + )); + + expect(find.text('hello world'), findsOneWidget); + final _RestorableWidgetState state = tester.state(find.byType(_RestorableWidget)); + + // Modify values. + state.setProperties(() { + state.numValue.value = 42.2; + state.doubleValue.value = 441.3; + state.intValue.value = 10; + state.stringValue.value = 'guten tag'; + state.boolValue.value = true; + state.controllerValue.value.text = 'blabla'; + state.objectValue.value = 53; + }); + await tester.pump(); + expect(find.text('guten tag'), findsOneWidget); + + final TestRestorationData restorationData = await tester.getRestorationData(); + + // Modify values. + state.setProperties(() { + state.numValue.value = 20; + state.doubleValue.value = 20.0; + state.intValue.value = 20; + state.stringValue.value = 'ciao'; + state.boolValue.value = false; + state.controllerValue.value.text = 'blub'; + state.objectValue.value = 20; + }); + await tester.pump(); + expect(find.text('ciao'), findsOneWidget); + final TextEditingController controller = state.controllerValue.value; + + // Restore to previous. + await tester.restoreFrom(restorationData); + expect(state.numValue.value, 42.2); + expect(state.doubleValue.value, 441.3); + expect(state.intValue.value, 10); + expect(state.stringValue.value, 'guten tag'); + expect(state.boolValue.value, true); + expect(state.controllerValue.value.text, 'blabla'); + expect(state.objectValue.value, 53); + expect(find.text('guten tag'), findsOneWidget); + expect(state.controllerValue.value, isNot(same(controller))); + + // Restore to empty data will re-initialize to default values. + await tester.restoreFrom(TestRestorationData.empty); + expect(state.numValue.value, 99); + expect(state.doubleValue.value, 123.2); + expect(state.intValue.value, 42); + expect(state.stringValue.value, 'hello world'); + expect(state.boolValue.value, false); + expect(state.controllerValue.value.text, 'FooBar'); + expect(state.objectValue.value, 55); + expect(find.text('hello world'), findsOneWidget); + }); + + testWidgets('call notifiers when value changes', (WidgetTester tester) async { + await tester.pumpWidget(const RootRestorationScope( + restorationId: 'root-child', + child: _RestorableWidget(), + )); + + expect(find.text('hello world'), findsOneWidget); + final _RestorableWidgetState state = tester.state(find.byType(_RestorableWidget)); + + final List notifyLog = []; + state.numValue.addListener(() { + notifyLog.add('num'); + }); + state.doubleValue.addListener(() { + notifyLog.add('double'); + }); + state.intValue.addListener(() { + notifyLog.add('int'); + }); + state.stringValue.addListener(() { + notifyLog.add('string'); + }); + state.boolValue.addListener(() { + notifyLog.add('bool'); + }); + state.controllerValue.addListener(() { + notifyLog.add('controller'); + }); + state.objectValue.addListener(() { + notifyLog.add('object'); + }); + + state.setProperties(() { + state.numValue.value = 42.2; + }); + expect(notifyLog.single, 'num'); + notifyLog.clear(); + + state.setProperties(() { + state.doubleValue.value = 42.2; + }); + expect(notifyLog.single, 'double'); + notifyLog.clear(); + + state.setProperties(() { + state.intValue.value = 45; + }); + expect(notifyLog.single, 'int'); + notifyLog.clear(); + + state.setProperties(() { + state.stringValue.value = 'bar'; + }); + expect(notifyLog.single, 'string'); + notifyLog.clear(); + + state.setProperties(() { + state.boolValue.value = true; + }); + expect(notifyLog.single, 'bool'); + notifyLog.clear(); + + state.setProperties(() { + state.controllerValue.value.text = 'foo'; + }); + expect(notifyLog.single, 'controller'); + notifyLog.clear(); + + state.setProperties(() { + state.objectValue.value = 42; + }); + expect(notifyLog.single, 'object'); + notifyLog.clear(); + + await tester.pump(); + expect(find.text('bar'), findsOneWidget); + + // Does not notify when set to same value. + state.setProperties(() { + state.numValue.value = 42.2; + state.doubleValue.value = 42.2; + state.intValue.value = 45; + state.stringValue.value = 'bar'; + state.boolValue.value = true; + state.controllerValue.value.text = 'foo'; + }); + expect(notifyLog, isEmpty); + }); + + testWidgets('RestorableValue calls didUpdateValue', (WidgetTester tester) async { + await tester.pumpWidget(const RootRestorationScope( + restorationId: 'root-child', + child: _RestorableWidget(), + )); + + expect(find.text('hello world'), findsOneWidget); + final _RestorableWidgetState state = tester.state(find.byType(_RestorableWidget)); + + expect(state.objectValue.didUpdateValueCallCount, 0); + + state.setProperties(() { + state.objectValue.value = 44; + }); + expect(state.objectValue.didUpdateValueCallCount, 1); + + await tester.pump(); + + state.setProperties(() { + state.objectValue.value = 44; + }); + expect(state.objectValue.didUpdateValueCallCount, 1); + }); +} + +class _TestRestorableValue extends RestorableValue { + @override + Object createDefaultValue() { + return 55; + } + + int didUpdateValueCallCount = 0; + + @override + void didUpdateValue(Object oldValue) { + didUpdateValueCallCount++; + notifyListeners(); + } + + @override + Object fromPrimitives(Object data) { + return data; + } + + @override + Object toPrimitives() { + return value; + } +} + +class _RestorableWidget extends StatefulWidget { + const _RestorableWidget({Key key}) : super(key: key); + + @override + State<_RestorableWidget> createState() => _RestorableWidgetState(); +} + +class _RestorableWidgetState extends State<_RestorableWidget> with RestorationMixin { + final RestorableNum numValue = RestorableNum(99); + final RestorableDouble doubleValue = RestorableDouble(123.2); + final RestorableInt intValue = RestorableInt(42); + final RestorableString stringValue = RestorableString('hello world'); + final RestorableBool boolValue = RestorableBool(false); + final RestorableTextEditingController controllerValue = RestorableTextEditingController(text: 'FooBar'); + final _TestRestorableValue objectValue = _TestRestorableValue(); + + @override + void restoreState(RestorationBucket oldBucket) { + registerForRestoration(numValue, 'num'); + registerForRestoration(doubleValue, 'double'); + registerForRestoration(intValue, 'int'); + registerForRestoration(stringValue, 'string'); + registerForRestoration(boolValue,'bool'); + registerForRestoration(controllerValue, 'controller'); + registerForRestoration(objectValue, 'object'); + } + + void setProperties(VoidCallback callback) { + setState(callback); + } + + @override + Widget build(BuildContext context) { + return Text(stringValue.value ?? 'null', textDirection: TextDirection.ltr,); + } + + @override + String get restorationId => 'widget'; +} diff --git a/packages/flutter/test/widgets/restoration.dart b/packages/flutter/test/widgets/restoration.dart new file mode 100644 index 00000000000..7b82d6a4cdc --- /dev/null +++ b/packages/flutter/test/widgets/restoration.dart @@ -0,0 +1,34 @@ +// Copyright 2014 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. + +// @dart = 2.8 + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +export '../services/restoration.dart'; + +class BucketSpy extends StatefulWidget { + const BucketSpy({Key key, this.child}) : super(key: key); + + final Widget child; + + @override + State createState() => BucketSpyState(); +} + +class BucketSpyState extends State { + RestorationBucket bucket; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + bucket = RestorationScope.of(context); + } + + @override + Widget build(BuildContext context) { + return widget.child ?? Container(); + } +} diff --git a/packages/flutter/test/widgets/restoration_mixin_test.dart b/packages/flutter/test/widgets/restoration_mixin_test.dart new file mode 100644 index 00000000000..14da5363ddd --- /dev/null +++ b/packages/flutter/test/widgets/restoration_mixin_test.dart @@ -0,0 +1,833 @@ +// Copyright 2014 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. + +// @dart = 2.8 + +import 'package:flutter/widgets.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'restoration.dart'; + +void main() { + testWidgets('claims bucket', (WidgetTester tester) async { + const String id = 'hello world 1234'; + final MockRestorationManager manager = MockRestorationManager(); + final Map rawData = {}; + final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: rawData); + expect(rawData, isEmpty); + + await tester.pumpWidget( + UnmanagedRestorationScope( + bucket: root, + child: const _TestRestorableWidget( + restorationId: id, + ), + ), + ); + manager.doSerialization(); + + final _TestRestorableWidgetState state = tester.state(find.byType(_TestRestorableWidget)); + expect(state.bucket.restorationId, id); + expect(rawData[childrenMapKey].containsKey(id), isTrue); + expect(state.property.value, 10); + expect(rawData[childrenMapKey][id][valuesMapKey]['foo'], 10); + expect(state.property.log, ['createDefaultValue', 'initWithValue', 'toPrimitives']); + expect(state.toogleBucketLog, isEmpty); + expect(state.restoreStateLog.single, isNull); + }); + + testWidgets('claimed bucket with data', (WidgetTester tester) async { + final MockRestorationManager manager = MockRestorationManager(); + final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: _createRawDataSet()); + + await tester.pumpWidget( + UnmanagedRestorationScope( + bucket: root, + child: const _TestRestorableWidget( + restorationId: 'child1', + ), + ), + ); + manager.doSerialization(); + + final _TestRestorableWidgetState state = tester.state(find.byType(_TestRestorableWidget)); + expect(state.bucket.restorationId, 'child1'); + expect(state.property.value, 22); + expect(state.property.log, ['fromPrimitives', 'initWithValue']); + expect(state.toogleBucketLog, isEmpty); + expect(state.restoreStateLog.single, isNull); + }); + + testWidgets('renames existing bucket when new ID is provided via widget', (WidgetTester tester) async { + final MockRestorationManager manager = MockRestorationManager(); + final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: _createRawDataSet()); + + await tester.pumpWidget( + UnmanagedRestorationScope( + bucket: root, + child: const _TestRestorableWidget( + restorationId: 'child1', + ), + ), + ); + manager.doSerialization(); + + // Claimed existing bucket with data. + final _TestRestorableWidgetState state = tester.state(find.byType(_TestRestorableWidget)); + expect(state.bucket.restorationId, 'child1'); + expect(state.bucket.read('foo'), 22); + final RestorationBucket bucket = state.bucket; + + state.property.log.clear(); + state.restoreStateLog.clear(); + + // Rename the existing bucket. + await tester.pumpWidget( + UnmanagedRestorationScope( + bucket: root, + child: const _TestRestorableWidget( + restorationId: 'something else', + ), + ), + ); + manager.doSerialization(); + + expect(state.bucket.restorationId, 'something else'); + expect(state.bucket.read('foo'), 22); + expect(state.bucket, same(bucket)); + expect(state.property.log, isEmpty); + expect(state.restoreStateLog, isEmpty); + expect(state.toogleBucketLog, isEmpty); + }); + + testWidgets('renames existing bucket when didUpdateRestorationId is called', (WidgetTester tester) async { + final MockRestorationManager manager = MockRestorationManager(); + final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: _createRawDataSet()); + + await tester.pumpWidget( + UnmanagedRestorationScope( + bucket: root, + child: const _TestRestorableWidget( + restorationId: 'child1', + ), + ), + ); + manager.doSerialization(); + + // Claimed existing bucket with data. + final _TestRestorableWidgetState state = tester.state(find.byType(_TestRestorableWidget)); + expect(state.bucket.restorationId, 'child1'); + expect(state.bucket.read('foo'), 22); + final RestorationBucket bucket = state.bucket; + + state.property.log.clear(); + state.restoreStateLog.clear(); + + // Rename the existing bucket. + state.injectId('newnewnew'); + manager.doSerialization(); + + expect(state.bucket.restorationId, 'newnewnew'); + expect(state.bucket.read('foo'), 22); + expect(state.bucket, same(bucket)); + expect(state.property.log, isEmpty); + expect(state.restoreStateLog, isEmpty); + expect(state.toogleBucketLog, isEmpty); + }); + + testWidgets('Disposing widget removes its data', (WidgetTester tester) async { + final MockRestorationManager manager = MockRestorationManager(); + final Map rawData = _createRawDataSet(); + final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: rawData); + + expect(rawData[childrenMapKey].containsKey('child1'), isTrue); + await tester.pumpWidget( + UnmanagedRestorationScope( + bucket: root, + child: const _TestRestorableWidget( + restorationId: 'child1', + ), + ), + ); + manager.doSerialization(); + expect(rawData[childrenMapKey].containsKey('child1'), isTrue); + + await tester.pumpWidget( + UnmanagedRestorationScope( + bucket: root, + child: Container(), + ), + ); + manager.doSerialization(); + + expect(rawData[childrenMapKey].containsKey('child1'), isFalse); + }); + + testWidgets('toggling id between null and non-null', (WidgetTester tester) async { + final MockRestorationManager manager = MockRestorationManager(); + final Map rawData = _createRawDataSet(); + final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: rawData); + + await tester.pumpWidget( + UnmanagedRestorationScope( + bucket: root, + child: const _TestRestorableWidget( + restorationId: null, + ), + ), + ); + final _TestRestorableWidgetState state = tester.state(find.byType(_TestRestorableWidget)); + expect(state.bucket, isNull); + expect(state.property.value, 10); // Initialized to default. + expect(rawData[childrenMapKey]['child1'][valuesMapKey]['foo'], 22); + expect(state.property.log, ['createDefaultValue', 'initWithValue']); + state.property.log.clear(); + expect(state.restoreStateLog.single, isNull); + expect(state.toogleBucketLog, isEmpty); + state.restoreStateLog.clear(); + state.toogleBucketLog.clear(); + + // Change id to non-null. + await tester.pumpWidget( + UnmanagedRestorationScope( + bucket: root, + child: const _TestRestorableWidget( + restorationId: 'child1', + ), + ), + ); + manager.doSerialization(); + expect(state.bucket, isNotNull); + expect(state.bucket.restorationId, 'child1'); + expect(state.property.value, 10); + expect(rawData[childrenMapKey]['child1'][valuesMapKey]['foo'], 10); + expect(state.property.log, ['toPrimitives']); + state.property.log.clear(); + expect(state.restoreStateLog, isEmpty); + expect(state.toogleBucketLog.single, isNull); + state.restoreStateLog.clear(); + state.toogleBucketLog.clear(); + + final RestorationBucket bucket = state.bucket; + + // Change id back to null. + await tester.pumpWidget( + UnmanagedRestorationScope( + bucket: root, + child: const _TestRestorableWidget( + restorationId: null, + ), + ), + ); + manager.doSerialization(); + expect(state.bucket, isNull); + expect(rawData[childrenMapKey].containsKey('child1'), isFalse); + expect(state.property.log, isEmpty); + expect(state.restoreStateLog, isEmpty); + expect(state.toogleBucketLog.single, same(bucket)); + }); + + testWidgets('move in and out of scope', (WidgetTester tester) async { + final Key key = GlobalKey(); + final MockRestorationManager manager = MockRestorationManager(); + final Map rawData = _createRawDataSet(); + final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: rawData); + + await tester.pumpWidget( + _TestRestorableWidget( + key: key, + restorationId: 'child1', + ), + ); + final _TestRestorableWidgetState state = tester.state(find.byType(_TestRestorableWidget)); + expect(state.bucket, isNull); + expect(state.property.value, 10); // Initialized to default. + expect(rawData[childrenMapKey]['child1'][valuesMapKey]['foo'], 22); + expect(state.property.log, ['createDefaultValue', 'initWithValue']); + state.property.log.clear(); + expect(state.restoreStateLog.single, isNull); + expect(state.toogleBucketLog, isEmpty); + state.restoreStateLog.clear(); + state.toogleBucketLog.clear(); + + // Move it under a valid scope. + await tester.pumpWidget( + UnmanagedRestorationScope( + bucket: root, + child: _TestRestorableWidget( + key: key, + restorationId: 'child1', + ), + ), + ); + manager.doSerialization(); + expect(state.bucket, isNotNull); + expect(state.bucket.restorationId, 'child1'); + expect(state.property.value, 10); + expect(rawData[childrenMapKey]['child1'][valuesMapKey]['foo'], 10); + expect(state.property.log, ['toPrimitives']); + state.property.log.clear(); + expect(state.restoreStateLog, isEmpty); + expect(state.toogleBucketLog.single, isNull); + state.restoreStateLog.clear(); + state.toogleBucketLog.clear(); + + final RestorationBucket bucket = state.bucket; + + // Move out of scope again. + await tester.pumpWidget( + _TestRestorableWidget( + key: key, + restorationId: 'child1', + ), + ); + manager.doSerialization(); + expect(state.bucket, isNull); + expect(rawData[childrenMapKey].containsKey('child1'), isFalse); + expect(state.property.log, isEmpty); + expect(state.restoreStateLog, isEmpty); + expect(state.toogleBucketLog.single, same(bucket)); + }); + + testWidgets('moving scope moves its data', (WidgetTester tester) async { + final MockRestorationManager manager = MockRestorationManager(); + final Map rawData = {}; + final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: rawData); + final Key key = GlobalKey(); + + await tester.pumpWidget( + UnmanagedRestorationScope( + bucket: root, + child: Row( + textDirection: TextDirection.ltr, + children: [ + RestorationScope( + restorationId: 'fixed', + child: _TestRestorableWidget( + key: key, + restorationId: 'moving-child', + ), + ), + ], + ), + ), + ); + manager.doSerialization(); + final _TestRestorableWidgetState state = tester.state(find.byType(_TestRestorableWidget)); + expect(state.bucket.restorationId, 'moving-child'); + expect(rawData[childrenMapKey]['fixed'][childrenMapKey].containsKey('moving-child'), isTrue); + final RestorationBucket bucket = state.bucket; + state.property.log.clear(); + state.restoreStateLog.clear(); + + state.bucket.write('value', 11); + manager.doSerialization(); + + // Move widget. + await tester.pumpWidget( + UnmanagedRestorationScope( + bucket: root, + child: Row( + textDirection: TextDirection.ltr, + children: [ + RestorationScope( + restorationId: 'fixed', + child: Container(), + ), + _TestRestorableWidget( + key: key, + restorationId: 'moving-child', + ), + ], + ), + ), + ); + manager.doSerialization(); + expect(state.bucket.restorationId, 'moving-child'); + expect(state.bucket, same(bucket)); + expect(state.bucket.read('value'), 11); + expect(state.property.log, isEmpty); + expect(state.toogleBucketLog, isEmpty); + expect(state.restoreStateLog, isEmpty); + + expect(rawData[childrenMapKey]['fixed'], isEmpty); + expect(rawData[childrenMapKey].containsKey('moving-child'), isTrue); + }); + + testWidgets('decommission claims new bucket with data', (WidgetTester tester) async { + final MockRestorationManager manager = MockRestorationManager(); + RestorationBucket root = RestorationBucket.root(manager: manager, rawData: {}); + + await tester.pumpWidget( + UnmanagedRestorationScope( + bucket: root, + child: const _TestRestorableWidget( + restorationId: 'child1', + ), + ), + ); + manager.doSerialization(); + final _TestRestorableWidgetState state = tester.state(find.byType(_TestRestorableWidget)); + expect(state.bucket.restorationId, 'child1'); + expect(state.property.value, 10); // Initialized to default. + expect(state.bucket.read('foo'), 10); + final RestorationBucket bucket = state.bucket; + state.property.log.clear(); + state.restoreStateLog.clear(); + + // Replace root bucket. + root..decommission()..dispose(); + root = RestorationBucket.root(manager: manager, rawData: _createRawDataSet()); + + await tester.pumpWidget( + UnmanagedRestorationScope( + bucket: root, + child: const _TestRestorableWidget( + restorationId: 'child1', + ), + ), + ); + + // Bucket has been replaced. + expect(state.bucket, isNot(same(bucket))); + expect(state.bucket.restorationId, 'child1'); + expect(state.property.value, 22); // Restored value. + expect(state.bucket.read('foo'), 22); + expect(state.restoreStateLog.single, bucket); + expect(state.toogleBucketLog, isEmpty); + expect(state.property.log, ['fromPrimitives', 'initWithValue']); + }); + + testWidgets('restartAndRestore', (WidgetTester tester) async { + await tester.pumpWidget( + const RootRestorationScope( + restorationId: 'root-child', + child: _TestRestorableWidget( + restorationId: 'widget', + ), + ) + ); + + _TestRestorableWidgetState state = tester.state(find.byType(_TestRestorableWidget)); + expect(state.bucket, isNotNull); + expect(state.property.value, 10); // default + expect(state.property.log, ['createDefaultValue', 'initWithValue', 'toPrimitives']); + expect(state.restoreStateLog.single, isNull); + expect(state.toogleBucketLog, isEmpty); + _clearLogs(state); + + state.setProperties(() { + state.property.value = 20; + }); + await tester.pump(); + expect(state.property.value, 20); + expect(state.property.log, ['toPrimitives']); + expect(state.restoreStateLog, isEmpty); + expect(state.toogleBucketLog, isEmpty); + _clearLogs(state); + + final _TestRestorableWidgetState oldState = state; + await tester.restartAndRestore(); + state = tester.state(find.byType(_TestRestorableWidget)); + + expect(state, isNot(same(oldState))); + expect(state.property.value, 20); + expect(state.property.log, ['fromPrimitives', 'initWithValue']); + expect(state.restoreStateLog.single, isNull); + expect(state.toogleBucketLog, isEmpty); + }); + + testWidgets('restore while running', (WidgetTester tester) async { + await tester.pumpWidget( + const RootRestorationScope( + restorationId: 'root-child', + child: _TestRestorableWidget( + restorationId: 'widget', + ), + ), + ); + + _TestRestorableWidgetState state = tester.state(find.byType(_TestRestorableWidget)); + + state.setProperties(() { + state.property.value = 20; + }); + await tester.pump(); + expect(state.property.value, 20); + + final TestRestorationData data = await tester.getRestorationData(); + + state.setProperties(() { + state.property.value = 30; + }); + await tester.pump(); + expect(state.property.value, 30); + _clearLogs(state); + + final _TestRestorableWidgetState oldState = state; + final RestorationBucket oldBucket = oldState.bucket; + await tester.restoreFrom(data); + state = tester.state(find.byType(_TestRestorableWidget)); + + expect(state, same(oldState)); + expect(state.property.value, 20); + expect(state.property.log, ['fromPrimitives', 'initWithValue']); + expect(state.restoreStateLog.single, oldBucket); + expect(state.toogleBucketLog, isEmpty); + }); + + testWidgets('can register additional property outside of restoreState', (WidgetTester tester) async { + await tester.pumpWidget( + const RootRestorationScope( + restorationId: 'root-child', + child: _TestRestorableWidget( + restorationId: 'widget', + ), + ), + ); + + final _TestRestorableWidgetState state = tester.state(find.byType(_TestRestorableWidget)); + state.registerAdditionalProperty(); + expect(state.additionalProperty.value, 11); + expect(state.additionalProperty.log, ['createDefaultValue', 'initWithValue', 'toPrimitives']); + + state.setProperties(() { + state.additionalProperty.value = 33; + }); + await tester.pump(); + expect(state.additionalProperty.value, 33); + + final TestRestorationData data = await tester.getRestorationData(); + + state.setProperties(() { + state.additionalProperty.value = 44; + }); + await tester.pump(); + expect(state.additionalProperty.value, 44); + _clearLogs(state); + + await tester.restoreFrom(data); + + expect(state, same(tester.state(find.byType(_TestRestorableWidget)))); + expect(state.additionalProperty.value, 33); + expect(state.property.log, ['fromPrimitives', 'initWithValue']); + }); + + testWidgets('cannot register same property twice', (WidgetTester tester) async { + await tester.pumpWidget( + const RootRestorationScope( + restorationId: 'root-child', + child: _TestRestorableWidget( + restorationId: 'widget', + ), + ), + ); + + final _TestRestorableWidgetState state = tester.state(find.byType(_TestRestorableWidget)); + state.registerAdditionalProperty(); + await tester.pump(); + expect(() => state.registerAdditionalProperty(), throwsAssertionError); + }); + + testWidgets('cannot register under ID that is already in use', (WidgetTester tester) async { + await tester.pumpWidget( + const RootRestorationScope( + restorationId: 'root-child', + child: _TestRestorableWidget( + restorationId: 'widget', + ), + ), + ); + + final _TestRestorableWidgetState state = tester.state(find.byType(_TestRestorableWidget)); + expect(() => state.registerPropertyUnderSameId(), throwsAssertionError); + }); + + testWidgets('data of disabled property is not stored', (WidgetTester tester) async { + await tester.pumpWidget( + const RootRestorationScope( + restorationId: 'root-child', + child: _TestRestorableWidget( + restorationId: 'widget', + ), + ), + ); + _TestRestorableWidgetState state = tester.state(find.byType(_TestRestorableWidget)); + + state.setProperties(() { + state.property.value = 30; + }); + await tester.pump(); + expect(state.property.value, 30); + expect(state.bucket.read('foo'), 30); + _clearLogs(state); + + state.setProperties(() { + state.property.enabled = false; + }); + await tester.pump(); + expect(state.property.value, 30); + expect(state.bucket.contains('foo'), isFalse); + expect(state.property.log, isEmpty); + + state.setProperties(() { + state.property.value = 40; + }); + await tester.pump(); + expect(state.bucket.contains('foo'), isFalse); + expect(state.property.log, isEmpty); + + await tester.restartAndRestore(); + state = tester.state(find.byType(_TestRestorableWidget)); + expect(state.property.log, ['createDefaultValue', 'initWithValue', 'toPrimitives']); + expect(state.property.value, 10); // Initialized to default value. + }); + + testWidgets('Enabling property stores its data again', (WidgetTester tester) async { + await tester.pumpWidget( + const RootRestorationScope( + restorationId: 'root-child', + child: _TestRestorableWidget( + restorationId: 'widget', + ), + ), + ); + _TestRestorableWidgetState state = tester.state(find.byType(_TestRestorableWidget)); + _clearLogs(state); + + state.setProperties(() { + state.property.enabled = false; + }); + await tester.pump(); + expect(state.bucket.contains('foo'), isFalse); + state.setProperties(() { + state.property.value = 40; + }); + await tester.pump(); + expect(state.property.value, 40); + expect(state.bucket.contains('foo'), isFalse); + expect(state.property.log, isEmpty); + + state.setProperties(() { + state.property.enabled = true; + }); + await tester.pump(); + expect(state.bucket.read('foo'), 40); + expect(state.property.log, ['toPrimitives']); + + await tester.restartAndRestore(); + state = tester.state(find.byType(_TestRestorableWidget)); + expect(state.property.log, ['fromPrimitives', 'initWithValue']); + expect(state.property.value, 40); + }); + + testWidgets('Unregistering a property removes its data', (WidgetTester tester) async { + await tester.pumpWidget( + const RootRestorationScope( + restorationId: 'root-child', + child: _TestRestorableWidget( + restorationId: 'widget', + ), + ), + ); + final _TestRestorableWidgetState state = tester.state(find.byType(_TestRestorableWidget)); + state.registerAdditionalProperty(); + await tester.pump(); + expect(state.additionalProperty.value, 11); + expect(state.bucket.read('additional'), 11); + state.unregisterAdditionalProperty(); + await tester.pump(); + expect(state.bucket.contains('additional'), isFalse); + expect(() => state.additionalProperty.value, throwsAssertionError); // No longer registered. + + // Can register the same property again. + state.registerAdditionalProperty(); + await tester.pump(); + expect(state.additionalProperty.value, 11); + expect(state.bucket.read('additional'), 11); + }); + + testWidgets('Disposing a property unregisters it, but keeps data', (WidgetTester tester) async { + await tester.pumpWidget( + const RootRestorationScope( + restorationId: 'root-child', + child: _TestRestorableWidget( + restorationId: 'widget', + ), + ), + ); + final _TestRestorableWidgetState state = tester.state(find.byType(_TestRestorableWidget)); + state.registerAdditionalProperty(); + await tester.pump(); + expect(state.additionalProperty.value, 11); + expect(state.bucket.read('additional'), 11); + + state.additionalProperty.dispose(); + await tester.pump(); + expect(state.bucket.read('additional'), 11); + + // Can register property under same id again. + state.additionalProperty = _TestRestorableProperty(22); + state.registerAdditionalProperty(); + await tester.pump(); + + expect(state.additionalProperty.value, 11); // Old value restored. + expect(state.bucket.read('additional'), 11); + }); + + test('RestorableProperty throws after disposed', () { + final RestorableProperty property = _TestRestorableProperty(10); + property.dispose(); + expect(() => property.dispose(), throwsFlutterError); + }); +} + +void _clearLogs(_TestRestorableWidgetState state) { + state.property.log.clear(); + state.additionalProperty?.log?.clear(); + state.restoreStateLog.clear(); + state.toogleBucketLog.clear(); +} + +class _TestRestorableWidget extends StatefulWidget { + + const _TestRestorableWidget({Key key, this.restorationId}) : super(key: key); + + final String restorationId; + + @override + State<_TestRestorableWidget> createState() => _TestRestorableWidgetState(); +} + +class _TestRestorableWidgetState extends State<_TestRestorableWidget> with RestorationMixin { + final _TestRestorableProperty property = _TestRestorableProperty(10); + _TestRestorableProperty additionalProperty; + bool _rerigisterAdditionalProperty = false; + + final List restoreStateLog = []; + final List toogleBucketLog = []; + + + @override + void restoreState(RestorationBucket oldBucket) { + restoreStateLog.add(oldBucket); + registerForRestoration(property, 'foo'); + if (_rerigisterAdditionalProperty && additionalProperty != null) { + registerForRestoration(additionalProperty, 'additional'); + } + } + + @override + void didToggleBucket(RestorationBucket oldBucket) { + toogleBucketLog.add(oldBucket); + super.didToggleBucket(oldBucket); + } + + @override + void dispose() { + super.dispose(); + property.dispose(); + additionalProperty?.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container(); + } + + void setProperties(VoidCallback fn) => setState(fn); + + String _injectedId; + void injectId(String id) { + _injectedId = id; + didUpdateRestorationId(); + } + + void registerAdditionalProperty({bool reregister = true}) { + additionalProperty ??= _TestRestorableProperty(11); + registerForRestoration(additionalProperty, 'additional'); + _rerigisterAdditionalProperty = reregister; + } + + void unregisterAdditionalProperty() { + unregisterFromRestoration(additionalProperty); + } + + void registerPropertyUnderSameId() { + registerForRestoration(_TestRestorableProperty(11), 'foo'); + } + + @override + String get restorationId => _injectedId ?? widget.restorationId; +} + +Map _createRawDataSet() { + return { + valuesMapKey: { + 'value1' : 10, + 'value2' : 'Hello', + }, + childrenMapKey: { + 'child1' : { + valuesMapKey : { + 'foo': 22, + } + }, + 'child2' : { + valuesMapKey : { + 'bar': 33, + } + }, + }, + }; +} + +class _TestRestorableProperty extends RestorableProperty { + _TestRestorableProperty(this._value); + + List log = []; + + @override + bool get enabled => _enabled; + bool _enabled = true; + set enabled(bool value) { + _enabled = value; + notifyListeners(); + } + + @override + Object createDefaultValue() { + log.add('createDefaultValue'); + return _value; + } + + @override + Object fromPrimitives(Object data) { + log.add('fromPrimitives'); + return data; + } + + Object get value { + assert(isRegistered); + return _value; + } + Object _value; + set value(Object value) { + _value = value; + notifyListeners(); + } + + @override + void initWithValue(Object v) { + log.add('initWithValue'); + _value = v; + } + + @override + Object toPrimitives() { + log.add('toPrimitives'); + return _value; + } +} diff --git a/packages/flutter/test/widgets/restoration_scope_test.dart b/packages/flutter/test/widgets/restoration_scope_test.dart new file mode 100644 index 00000000000..f9902e12a4d --- /dev/null +++ b/packages/flutter/test/widgets/restoration_scope_test.dart @@ -0,0 +1,387 @@ +// Copyright 2014 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. + +// @dart = 2.8 + +import 'package:flutter/widgets.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'restoration.dart'; + +void main() { + group('UnmanagedRestorationScope', () { + testWidgets('makes bucket available to descendants', (WidgetTester tester) async { + final RestorationBucket bucket1 = RestorationBucket.empty( + restorationId: 'foo', + debugOwner: 'owner', + ); + + await tester.pumpWidget( + UnmanagedRestorationScope( + bucket: bucket1, + child: const BucketSpy(), + ), + ); + + final BucketSpyState state = tester.state(find.byType(BucketSpy)); + expect(state.bucket, bucket1); + + // Notifies when bucket changes. + final RestorationBucket bucket2 = RestorationBucket.empty( + restorationId: 'foo2', + debugOwner: 'owner', + ); + await tester.pumpWidget( + UnmanagedRestorationScope( + bucket: bucket2, + child: const BucketSpy(), + ), + ); + expect(state.bucket, bucket2); + }); + + testWidgets('null bucket disables restoration', (WidgetTester tester) async { + await tester.pumpWidget( + const UnmanagedRestorationScope( + bucket: null, + child: BucketSpy(), + ), + ); + final BucketSpyState state = tester.state(find.byType(BucketSpy)); + expect(state.bucket, isNull); + }); + }); + + group('RestorationScope', () { + testWidgets('makes bucket available to descendants', (WidgetTester tester) async { + const String id = 'hello world 1234'; + final MockRestorationManager manager = MockRestorationManager(); + final Map rawData = {}; + final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: rawData); + expect(rawData, isEmpty); + + await tester.pumpWidget( + UnmanagedRestorationScope( + bucket: root, + child: const RestorationScope( + restorationId: id, + child: BucketSpy(), + ), + ), + ); + manager.doSerialization(); + + final BucketSpyState state = tester.state(find.byType(BucketSpy)); + expect(state.bucket.restorationId, id); + expect(rawData[childrenMapKey].containsKey(id), isTrue); + }); + + testWidgets('bucket for descendants contains data claimed from parent', (WidgetTester tester) async { + final MockRestorationManager manager = MockRestorationManager(); + final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: _createRawDataSet()); + + await tester.pumpWidget( + UnmanagedRestorationScope( + bucket: root, + child: const RestorationScope( + restorationId: 'child1', + child: BucketSpy(), + ), + ), + ); + manager.doSerialization(); + + final BucketSpyState state = tester.state(find.byType(BucketSpy)); + expect(state.bucket.restorationId, 'child1'); + expect(state.bucket.read('foo'), 22); + }); + + testWidgets('renames existing bucket when new ID is provided', (WidgetTester tester) async { + final MockRestorationManager manager = MockRestorationManager(); + final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: _createRawDataSet()); + + await tester.pumpWidget( + UnmanagedRestorationScope( + bucket: root, + child: const RestorationScope( + restorationId: 'child1', + child: BucketSpy(), + ), + ), + ); + manager.doSerialization(); + + // Claimed existing bucket with data. + final BucketSpyState state = tester.state(find.byType(BucketSpy)); + expect(state.bucket.restorationId, 'child1'); + expect(state.bucket.read('foo'), 22); + final RestorationBucket bucket = state.bucket; + + // Rename the existing bucket. + await tester.pumpWidget( + UnmanagedRestorationScope( + bucket: root, + child: const RestorationScope( + restorationId: 'something else', + child: BucketSpy(), + ), + ), + ); + manager.doSerialization(); + + expect(state.bucket.restorationId, 'something else'); + expect(state.bucket.read('foo'), 22); + expect(state.bucket, same(bucket)); + }); + + testWidgets('Disposing a scope removes its data', (WidgetTester tester) async { + final MockRestorationManager manager = MockRestorationManager(); + final Map rawData = _createRawDataSet(); + final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: rawData); + + expect(rawData[childrenMapKey].containsKey('child1'), isTrue); + await tester.pumpWidget( + UnmanagedRestorationScope( + bucket: root, + child: const RestorationScope( + restorationId: 'child1', + child: BucketSpy(), + ), + ), + ); + manager.doSerialization(); + expect(rawData[childrenMapKey].containsKey('child1'), isTrue); + + await tester.pumpWidget( + UnmanagedRestorationScope( + bucket: root, + child: Container(), + ), + ); + manager.doSerialization(); + + expect(rawData[childrenMapKey].containsKey('child1'), isFalse); + }); + + testWidgets('no bucket for descendants when id is null', (WidgetTester tester) async { + final MockRestorationManager manager = MockRestorationManager(); + final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: {}); + + await tester.pumpWidget( + UnmanagedRestorationScope( + bucket: root, + child: const RestorationScope( + restorationId: null, + child: BucketSpy(), + ), + ), + ); + final BucketSpyState state = tester.state(find.byType(BucketSpy)); + expect(state.bucket, isNull); + + // Change id to non-null. + await tester.pumpWidget( + UnmanagedRestorationScope( + bucket: root, + child: const RestorationScope( + restorationId: 'foo', + child: BucketSpy(), + ), + ), + ); + manager.doSerialization(); + expect(state.bucket, isNotNull); + expect(state.bucket.restorationId, 'foo'); + + // Change id back to null. + await tester.pumpWidget( + UnmanagedRestorationScope( + bucket: root, + child: const RestorationScope( + restorationId: null, + child: BucketSpy(), + ), + ), + ); + manager.doSerialization(); + expect(state.bucket, isNull); + }); + + testWidgets('no bucket for descendants when scope is null', (WidgetTester tester) async { + final Key scopeKey = GlobalKey(); + + await tester.pumpWidget( + RestorationScope( + key: scopeKey, + restorationId: 'foo', + child: const BucketSpy(), + ), + ); + final BucketSpyState state = tester.state(find.byType(BucketSpy)); + expect(state.bucket, isNull); + + // Move it under a valid scope. + final MockRestorationManager manager = MockRestorationManager(); + final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: {}); + await tester.pumpWidget( + UnmanagedRestorationScope( + bucket: root, + child: RestorationScope( + key: scopeKey, + restorationId: 'foo', + child: const BucketSpy(), + ), + ), + ); + manager.doSerialization(); + expect(state.bucket, isNotNull); + expect(state.bucket.restorationId, 'foo'); + + // Move out of scope again. + await tester.pumpWidget( + RestorationScope( + key: scopeKey, + restorationId: 'foo', + child: const BucketSpy(), + ), + ); + manager.doSerialization(); + expect(state.bucket, isNull); + }); + + testWidgets('no bucket for descendants when scope and id are null', (WidgetTester tester) async { + await tester.pumpWidget( + const RestorationScope( + restorationId: null, + child: BucketSpy(), + ), + ); + final BucketSpyState state = tester.state(find.byType(BucketSpy)); + expect(state.bucket, isNull); + }); + + testWidgets('moving scope moves its data', (WidgetTester tester) async { + final MockRestorationManager manager = MockRestorationManager(); + final Map rawData = {}; + final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: rawData); + final Key scopeKey = GlobalKey(); + + await tester.pumpWidget( + UnmanagedRestorationScope( + bucket: root, + child: Row( + textDirection: TextDirection.ltr, + children: [ + RestorationScope( + restorationId: 'fixed', + child: RestorationScope( + key: scopeKey, + restorationId: 'moving-child', + child: const BucketSpy(), + ), + ), + ], + ), + ), + ); + manager.doSerialization(); + final BucketSpyState state = tester.state(find.byType(BucketSpy)); + expect(state.bucket.restorationId, 'moving-child'); + expect(rawData[childrenMapKey]['fixed'][childrenMapKey].containsKey('moving-child'), isTrue); + final RestorationBucket bucket = state.bucket; + + state.bucket.write('value', 11); + manager.doSerialization(); + + // Move scope. + await tester.pumpWidget( + UnmanagedRestorationScope( + bucket: root, + child: Row( + textDirection: TextDirection.ltr, + children: [ + RestorationScope( + restorationId: 'fixed', + child: Container(), + ), + RestorationScope( + key: scopeKey, + restorationId: 'moving-child', + child: const BucketSpy(), + ), + ], + ), + ), + ); + manager.doSerialization(); + expect(state.bucket.restorationId, 'moving-child'); + expect(state.bucket, same(bucket)); + expect(state.bucket.read('value'), 11); + + expect(rawData[childrenMapKey]['fixed'], isEmpty); + expect(rawData[childrenMapKey].containsKey('moving-child'), isTrue); + }); + + testWidgets('decommission claims new bucket with data', (WidgetTester tester) async { + final MockRestorationManager manager = MockRestorationManager(); + RestorationBucket root = RestorationBucket.root(manager: manager, rawData: {}); + + await tester.pumpWidget( + UnmanagedRestorationScope( + bucket: root, + child: const RestorationScope( + restorationId: 'child1', + child: BucketSpy(), + ), + ), + ); + manager.doSerialization(); + final BucketSpyState state = tester.state(find.byType(BucketSpy)); + expect(state.bucket.restorationId, 'child1'); + expect(state.bucket.read('foo'), isNull); // Does not exist. + final RestorationBucket bucket = state.bucket; + + // Replace root bucket. + root..decommission()..dispose(); + root = RestorationBucket.root(manager: manager, rawData: _createRawDataSet()); + + await tester.pumpWidget( + UnmanagedRestorationScope( + bucket: root, + child: const RestorationScope( + restorationId: 'child1', + child: BucketSpy(), + ), + ), + ); + + // Bucket has been replaced. + expect(state.bucket, isNot(same(bucket))); + expect(state.bucket.restorationId, 'child1'); + expect(state.bucket.read('foo'), 22); + }); + }); +} + +Map _createRawDataSet() { + return { + valuesMapKey: { + 'value1' : 10, + 'value2' : 'Hello', + }, + childrenMapKey: { + 'child1' : { + valuesMapKey : { + 'foo': 22, + } + }, + 'child2' : { + valuesMapKey : { + 'bar': 33, + } + }, + }, + }; +} diff --git a/packages/flutter/test/widgets/root_restoration_scope_test.dart b/packages/flutter/test/widgets/root_restoration_scope_test.dart new file mode 100644 index 00000000000..f8eae298446 --- /dev/null +++ b/packages/flutter/test/widgets/root_restoration_scope_test.dart @@ -0,0 +1,400 @@ +// Copyright 2014 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. + +// @dart = 2.8 + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'restoration.dart'; + +void main() { + final TestAutomatedTestWidgetsFlutterBinding binding = TestAutomatedTestWidgetsFlutterBinding(); + + setUp(() { + binding._restorationManager = MockRestorationManager(); + }); + + testWidgets('does not inject root bucket if inside scope', (WidgetTester tester) async { + final MockRestorationManager manager = MockRestorationManager(); + final Map rawData = {}; + final RestorationBucket root = RestorationBucket.root(manager: manager, rawData: rawData); + expect(rawData, isEmpty); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: UnmanagedRestorationScope( + bucket: root, + child: const RootRestorationScope( + restorationId: 'root-child', + child: BucketSpy( + child: Text('Hello'), + ), + ), + ), + ), + ); + manager.doSerialization(); + + expect(binding.restorationManager.rootBucketAccessed, 0); + final BucketSpyState state = tester.state(find.byType(BucketSpy)); + expect(state.bucket.restorationId, 'root-child'); + expect(rawData[childrenMapKey].containsKey('root-child'), isTrue); + + expect(find.text('Hello'), findsOneWidget); + }); + + testWidgets('waits for root bucket', (WidgetTester tester) async { + final Completer bucketCompleter = Completer(); + binding.restorationManager.rootBucket = bucketCompleter.future; + + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: RootRestorationScope( + restorationId: 'root-child', + child: BucketSpy( + child: Text('Hello'), + ), + ), + ), + ); + + expect(binding.restorationManager.rootBucketAccessed, 1); + // Child rendering is delayed until root bucket is available. + expect(find.text('Hello'), findsNothing); + expect(binding.firstFrameIsDeferred, isTrue); + + // Complete the future. + final Map rawData = {}; + final RestorationBucket root = RestorationBucket.root(manager: binding.restorationManager, rawData: rawData); + bucketCompleter.complete(root); + await tester.pump(const Duration(milliseconds: 100)); + + expect(binding.restorationManager.rootBucketAccessed, 1); + expect(find.text('Hello'), findsOneWidget); + expect(binding.firstFrameIsDeferred, isFalse); + + final BucketSpyState state = tester.state(find.byType(BucketSpy)); + expect(state.bucket.restorationId, 'root-child'); + expect(rawData[childrenMapKey].containsKey('root-child'), isTrue); + }); + + testWidgets('no delay when root is available synchronously', (WidgetTester tester) async { + final Map rawData = {}; + final RestorationBucket root = RestorationBucket.root(manager: binding.restorationManager, rawData: rawData); + binding.restorationManager.rootBucket = SynchronousFuture(root); + + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: RootRestorationScope( + restorationId: 'root-child', + child: BucketSpy( + child: Text('Hello'), + ), + ), + ), + ); + + expect(binding.restorationManager.rootBucketAccessed, 1); + expect(find.text('Hello'), findsOneWidget); + expect(binding.firstFrameIsDeferred, isFalse); + + final BucketSpyState state = tester.state(find.byType(BucketSpy)); + expect(state.bucket.restorationId, 'root-child'); + expect(rawData[childrenMapKey].containsKey('root-child'), isTrue); + }); + + testWidgets('does not insert root when restoration id is null', (WidgetTester tester) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: RootRestorationScope( + restorationId: null, + child: BucketSpy( + child: Text('Hello'), + ), + ), + ), + ); + + expect(binding.restorationManager.rootBucketAccessed, 0); + expect(find.text('Hello'), findsOneWidget); + expect(binding.firstFrameIsDeferred, isFalse); + + final BucketSpyState state = tester.state(find.byType(BucketSpy)); + expect(state.bucket, isNull); + + // Change restoration id to non-null. + final Completer bucketCompleter = Completer(); + binding.restorationManager.rootBucket = bucketCompleter.future; + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: RootRestorationScope( + restorationId: 'root-child', + child: BucketSpy( + child: Text('Hello'), + ), + ), + ), + ); + + expect(binding.restorationManager.rootBucketAccessed, 1); + expect(find.text('Hello'), findsOneWidget); + expect(state.bucket, isNull); // root bucket future has not completed yet. + + // Complete the future. + final RestorationBucket root = RestorationBucket.root(manager: binding.restorationManager, rawData: {}); + bucketCompleter.complete(root); + await tester.pump(const Duration(milliseconds: 100)); + + expect(binding.restorationManager.rootBucketAccessed, 1); + expect(find.text('Hello'), findsOneWidget); + expect(state.bucket.restorationId, 'root-child'); + + // Change ID back to null. + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: RootRestorationScope( + restorationId: null, + child: BucketSpy( + child: Text('Hello'), + ), + ), + ), + ); + + expect(binding.restorationManager.rootBucketAccessed, 1); + expect(find.text('Hello'), findsOneWidget); + expect(state.bucket, isNull); + }); + + testWidgets('injects root bucket when moved out of scope', (WidgetTester tester) async { + final Key rootScopeKey = GlobalKey(); + final MockRestorationManager manager = MockRestorationManager(); + final Map inScopeRawData = {}; + final RestorationBucket inScopeRootBucket = RestorationBucket.root(manager: manager, rawData: inScopeRawData); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: UnmanagedRestorationScope( + bucket: inScopeRootBucket, + child: RootRestorationScope( + key: rootScopeKey, + restorationId: 'root-child', + child: const BucketSpy( + child: Text('Hello'), + ), + ), + ), + ), + ); + + expect(binding.restorationManager.rootBucketAccessed, 0); + expect(find.text('Hello'), findsOneWidget); + final BucketSpyState state = tester.state(find.byType(BucketSpy)); + expect(state.bucket.restorationId, 'root-child'); + expect(inScopeRawData[childrenMapKey].containsKey('root-child'), isTrue); + + // Move out of scope. + final Completer bucketCompleter = Completer(); + binding.restorationManager.rootBucket = bucketCompleter.future; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: RootRestorationScope( + key: rootScopeKey, + restorationId: 'root-child', + child: const BucketSpy( + child: Text('Hello'), + ), + ), + ), + ); + + expect(binding.restorationManager.rootBucketAccessed, 1); + expect(find.text('Hello'), findsOneWidget); + + final Map outOfScopeRawData = {}; + final RestorationBucket outOfScopeRootBucket = RestorationBucket.root(manager: binding.restorationManager, rawData: outOfScopeRawData); + bucketCompleter.complete(outOfScopeRootBucket); + await tester.pump(const Duration(milliseconds: 100)); + + expect(binding.restorationManager.rootBucketAccessed, 1); + expect(find.text('Hello'), findsOneWidget); + expect(state.bucket.restorationId, 'root-child'); + expect(outOfScopeRawData[childrenMapKey].containsKey('root-child'), isTrue); + expect(inScopeRawData, isEmpty); + + // Move into scope. + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: UnmanagedRestorationScope( + bucket: inScopeRootBucket, + child: RootRestorationScope( + key: rootScopeKey, + restorationId: 'root-child', + child: const BucketSpy( + child: Text('Hello'), + ), + ), + ), + ), + ); + + expect(binding.restorationManager.rootBucketAccessed, 1); + expect(find.text('Hello'), findsOneWidget); + expect(state.bucket.restorationId, 'root-child'); + expect(outOfScopeRawData, isEmpty); + expect(inScopeRawData[childrenMapKey].containsKey('root-child'), isTrue); + }); + + testWidgets('injects new root when old one is decommissioned', (WidgetTester tester) async { + final Map firstRawData = {}; + final RestorationBucket firstRoot = RestorationBucket.root(manager: binding.restorationManager, rawData: firstRawData); + binding.restorationManager.rootBucket = SynchronousFuture(firstRoot); + + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: RootRestorationScope( + restorationId: 'root-child', + child: BucketSpy( + child: Text('Hello'), + ), + ), + ), + ); + + expect(binding.restorationManager.rootBucketAccessed, 1); + expect(find.text('Hello'), findsOneWidget); + final BucketSpyState state = tester.state(find.byType(BucketSpy)); + state.bucket.write('foo', 42); + expect(firstRawData[childrenMapKey]['root-child'][valuesMapKey]['foo'], 42); + final RestorationBucket firstBucket = state.bucket; + + // Replace with new root. + final Map secondRawData = { + childrenMapKey: { + 'root-child': { + valuesMapKey: { + 'foo': 22, + }, + }, + }, + }; + final RestorationBucket secondRoot = RestorationBucket.root(manager: binding.restorationManager, rawData: secondRawData); + binding.restorationManager.rootBucket = SynchronousFuture(secondRoot); + firstRoot..decommission()..dispose(); + await tester.pump(); + + expect(state.bucket, isNot(same(firstBucket))); + expect(state.bucket.read('foo'), 22); + }); + + testWidgets('injects null when rootBucket is null', (WidgetTester tester) async { + final Completer completer = Completer(); + binding.restorationManager.rootBucket = completer.future; + + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: RootRestorationScope( + restorationId: 'root-child', + child: BucketSpy( + child: Text('Hello'), + ), + ), + ), + ); + + expect(binding.restorationManager.rootBucketAccessed, 1); + expect(find.text('Hello'), findsNothing); + + completer.complete(null); + await tester.pump(const Duration(milliseconds: 100)); + + expect(binding.restorationManager.rootBucketAccessed, 1); + expect(find.text('Hello'), findsOneWidget); + + final BucketSpyState state = tester.state(find.byType(BucketSpy)); + expect(state.bucket, isNull); + + final RestorationBucket root = RestorationBucket.root(manager: binding.restorationManager, rawData: null); + binding.restorationManager.rootBucket = SynchronousFuture(root); + await tester.pump(); + + expect(binding.restorationManager.rootBucketAccessed, 2); + expect(find.text('Hello'), findsOneWidget); + expect(state.bucket, isNotNull); + }); + + testWidgets('can switch to null', (WidgetTester tester) async { + final RestorationBucket root = RestorationBucket.root(manager: binding.restorationManager, rawData: null); + binding.restorationManager.rootBucket = SynchronousFuture(root); + + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: RootRestorationScope( + restorationId: 'root-child', + child: BucketSpy( + child: Text('Hello'), + ), + ), + ), + ); + + expect(binding.restorationManager.rootBucketAccessed, 1); + expect(find.text('Hello'), findsOneWidget); + final BucketSpyState state = tester.state(find.byType(BucketSpy)); + expect(state.bucket, isNotNull); + + binding.restorationManager.rootBucket = SynchronousFuture(null); + root..decommission()..dispose(); + await tester.pump(); + + expect(binding.restorationManager.rootBucketAccessed, 2); + expect(find.text('Hello'), findsOneWidget); + expect(state.bucket, isNull); + }); +} + +class TestAutomatedTestWidgetsFlutterBinding extends AutomatedTestWidgetsFlutterBinding { + MockRestorationManager _restorationManager; + + @override + MockRestorationManager get restorationManager => _restorationManager; + + @override + TestRestorationManager createRestorationManager() { + return null; + } + + int _deferred = 0; + + bool get firstFrameIsDeferred => _deferred > 0; + + @override + void deferFirstFrame() { + _deferred++; + super.deferFirstFrame(); + } + + @override + void allowFirstFrame() { + _deferred--; + super.allowFirstFrame(); + } +} diff --git a/packages/flutter_test/lib/flutter_test.dart b/packages/flutter_test/lib/flutter_test.dart index fa51a23a8fd..5133106993f 100644 --- a/packages/flutter_test/lib/flutter_test.dart +++ b/packages/flutter_test/lib/flutter_test.dart @@ -58,6 +58,7 @@ export 'src/goldens.dart'; export 'src/matchers.dart'; export 'src/nonconst.dart'; export 'src/platform.dart'; +export 'src/restoration.dart'; export 'src/stack_manipulation.dart'; export 'src/test_async_utils.dart'; export 'src/test_compat.dart'; diff --git a/packages/flutter_test/lib/src/binding.dart b/packages/flutter_test/lib/src/binding.dart index 685406bbbf3..4bf158d97f8 100644 --- a/packages/flutter_test/lib/src/binding.dart +++ b/packages/flutter_test/lib/src/binding.dart @@ -23,6 +23,7 @@ import 'package:vector_math/vector_math_64.dart'; import '_binding_io.dart' if (dart.library.html) '_binding_web.dart' as binding; import 'goldens.dart'; import 'platform.dart'; +import 'restoration.dart'; import 'stack_manipulation.dart'; import 'test_async_utils.dart'; import 'test_exception_reporter.dart'; @@ -187,6 +188,21 @@ abstract class TestWidgetsFlutterBinding extends BindingBase TestWindow get window => _window; final TestWindow _window; + @override + TestRestorationManager get restorationManager => _restorationManager; + TestRestorationManager _restorationManager; + + /// Called by the test framework at the beginning of a widget test to + /// prepare the binding for the next test. + void reset() { + _restorationManager = createRestorationManager(); + } + + @override + TestRestorationManager createRestorationManager() { + return TestRestorationManager(); + } + /// The value to set [debugPrint] to while tests are running. /// /// This can be used to redirect console output from the framework, or to diff --git a/packages/flutter_test/lib/src/restoration.dart b/packages/flutter_test/lib/src/restoration.dart new file mode 100644 index 00000000000..2633c5efd4a --- /dev/null +++ b/packages/flutter_test/lib/src/restoration.dart @@ -0,0 +1,85 @@ +// Copyright 2014 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 'dart:typed_data'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +/// The [RestorationManager] used for tests. +/// +/// Unlike the real [RestorationManager], this one just keeps the restoration +/// data in memory and does not make it available to the engine. +class TestRestorationManager extends RestorationManager { + /// Creates a [TestRestorationManager]. + TestRestorationManager() { + // Ensures that [rootBucket] always returns a synchronous future to avoid + // extra pumps in tests. + restoreFrom(TestRestorationData.empty); + } + + @override + Future get rootBucket { + _debugRootBucketAccessed = true; + return super.rootBucket; + } + + /// The current restoration data from which the current state can be restored. + /// + /// To restore the state to the one described by this data, pass the + /// [TestRestorationData] obtained from this getter back to [restoreFrom]. + /// + /// See also: + /// + /// * [WidgetTester.getRestorationData], which makes this data available + /// in a widget test. + TestRestorationData get restorationData => _restorationData; + TestRestorationData _restorationData; + + /// Whether the [rootBucket] has been obtained. + bool get debugRootBucketAccessed => _debugRootBucketAccessed; + bool _debugRootBucketAccessed = false; + + /// Restores the state from the provided [TestRestorationData]. + /// + /// The restoration data obtained form [restorationData] can be passed into + /// this method to restore the state to what it was when the restoration data + /// was originally retrieved. + /// + /// See also: + /// + /// * [WidgetTester.restoreFrom], which exposes this method to a widget test. + void restoreFrom(TestRestorationData data) { + _restorationData = data; + handleRestorationUpdateFromEngine(enabled: true, data: data.binary); + } + + @override + Future sendToEngine(Uint8List encodedData) async { + _restorationData = TestRestorationData._(encodedData); + } +} + +/// Restoration data that can be used to restore the state to the one described +/// by this data. +/// +/// See also: +/// +/// * [WidgetTester.getRestorationData], which retrieves the restoration data +/// from the widget under test. +/// * [WidgetTester.restoreFrom], which takes a [TestRestorationData] to +/// restore the state of the widget under test from the provided data. +class TestRestorationData { + const TestRestorationData._(this.binary); + + /// Empty restoration data indicating that no data is available to restore + /// state from. + static const TestRestorationData empty = TestRestorationData._(null); + + /// The serialized representation of the restoration data. + /// + /// Should only be accessed by the test framework. + @protected + final Uint8List binary; +} diff --git a/packages/flutter_test/lib/src/widget_tester.dart b/packages/flutter_test/lib/src/widget_tester.dart index ffc353b8302..64b663e441e 100644 --- a/packages/flutter_test/lib/src/widget_tester.dart +++ b/packages/flutter_test/lib/src/widget_tester.dart @@ -23,6 +23,7 @@ import 'controller.dart'; import 'event_simulation.dart'; import 'finders.dart'; import 'matchers.dart'; +import 'restoration.dart'; import 'test_async_utils.dart'; import 'test_compat.dart'; import 'test_pointer.dart'; @@ -137,6 +138,7 @@ void testWidgets( test_package.addTearDown(binding.postTest); return binding.runTest( () async { + binding.reset(); debugResetSemanticsIdCounter(); tester.resetTestTextInput(); Object memento; @@ -678,6 +680,53 @@ class WidgetTester extends WidgetController implements HitTestDispatcher, Ticker }); } + /// Simulates restoring the state of the widget tree after the application + /// is restarted. + /// + /// The method grabs the current serialized restoration data from the + /// [RestorationManager], takes down the widget tree to destroy all in-memory + /// state, and then restores the widget tree from the serialized restoration + /// data. + Future restartAndRestore() async { + assert( + binding.restorationManager.debugRootBucketAccessed, + 'The current widget tree did not inject the root bucket of the RestorationManager and ' + 'therefore no restoration data has been collected to restore from. Did you forget to wrap ' + 'your widget tree in a RootRestorationScope?', + ); + final Widget widget = (binding.renderViewElement as RenderObjectToWidgetElement).widget.child; + final TestRestorationData restorationData = binding.restorationManager.restorationData; + runApp(Container(key: UniqueKey())); + await pump(); + binding.restorationManager.restoreFrom(restorationData); + return pumpWidget(widget); + } + + /// Retrieves the current restoration data from the [RestorationManager]. + /// + /// The returned [TestRestorationData] describes the current state of the + /// widget tree under test and can be provided to [restoreFrom] to restore + /// the widget tree to the state described by this data. + Future getRestorationData() async { + assert( + binding.restorationManager.debugRootBucketAccessed, + 'The current widget tree did not inject the root bucket of the RestorationManager and ' + 'therefore no restoration data has been collected. Did you forget to wrap your widget tree ' + 'in a RootRestorationScope?', + ); + return binding.restorationManager.restorationData; + } + + /// Restores the widget tree under test to the state described by the + /// provided [TestRestorationData]. + /// + /// The data provided to this method is usually obtained from + /// [getRestorationData]. + Future restoreFrom(TestRestorationData data) { + binding.restorationManager.restoreFrom(data); + return pump(); + } + /// Runs a [callback] that performs real asynchronous work. /// /// This is intended for callers who need to call asynchronous methods where diff --git a/packages/flutter_test/test/restoration_test.dart b/packages/flutter_test/test/restoration_test.dart new file mode 100644 index 00000000000..f8a6d01b67e --- /dev/null +++ b/packages/flutter_test/test/restoration_test.dart @@ -0,0 +1,111 @@ +// Copyright 2014 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:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('restartAndRestore', (WidgetTester tester) async { + await tester.pumpWidget( + const RootRestorationScope( + restorationId: 'root-child', + child: _RestorableWidget( + restorationId: 'restorable-widget', + ), + ), + ); + + final _RestorableWidgetState state = tester.state(find.byType(_RestorableWidget)); + expect(find.text('Hello World 100'), findsOneWidget); + expect(state.doubleValue, 1.0); + + state.setValues('Guten Morgen', 200, 33.4); + await tester.pump(); + + expect(find.text('Guten Morgen 200'), findsOneWidget); + expect(state.doubleValue, 33.4); + + await tester.restartAndRestore(); + + expect(find.text('Guten Morgen 200'), findsOneWidget); + expect(find.text('Hello World 100'), findsNothing); + final _RestorableWidgetState restoredState = tester.state(find.byType(_RestorableWidget)); + expect(restoredState, isNot(same(state))); + expect(restoredState.doubleValue, 1.0); + }); + + testWidgets('restore from previous restoration data', (WidgetTester tester) async { + await tester.pumpWidget( + const RootRestorationScope( + restorationId: 'root-child', + child: _RestorableWidget( + restorationId: 'restorable-widget', + ), + ), + ); + + final _RestorableWidgetState state = tester.state(find.byType(_RestorableWidget)); + expect(find.text('Hello World 100'), findsOneWidget); + expect(state.doubleValue, 1.0); + + state.setValues('Guten Morgen', 200, 33.4); + await tester.pump(); + + expect(find.text('Guten Morgen 200'), findsOneWidget); + expect(state.doubleValue, 33.4); + + final TestRestorationData data = await tester.getRestorationData(); + + state.setValues('See you later!', 400, 123.5); + await tester.pump(); + + expect(find.text('See you later! 400'), findsOneWidget); + expect(state.doubleValue, 123.5); + + await tester.restoreFrom(data); + + expect(tester.state(find.byType(_RestorableWidget)), same(state)); + expect(find.text('Guten Morgen 200'), findsOneWidget); + expect(state.doubleValue, 123.5); + }); +} + +class _RestorableWidget extends StatefulWidget { + const _RestorableWidget({Key key, this.restorationId}) : super(key: key); + + final String restorationId; + + @override + State<_RestorableWidget> createState() => _RestorableWidgetState(); +} + +class _RestorableWidgetState extends State<_RestorableWidget> with RestorationMixin { + final RestorableString stringValue = RestorableString('Hello World'); + final RestorableInt intValue = RestorableInt(100); + + double doubleValue = 1.0; // Not restorable. + + @override + void restoreState(RestorationBucket oldBucket) { + registerForRestoration(stringValue, 'string'); + registerForRestoration(intValue, 'int'); + } + + void setValues(String s, int i, double d) { + setState(() { + stringValue.value = s; + intValue.value = i; + doubleValue = d; + }); + } + + @override + Widget build(BuildContext context) { + return Text('${stringValue.value} ${intValue.value}', textDirection: TextDirection.ltr); + } + + @override + String get restorationId => widget.restorationId; +}