Line data Source code
1 : part of flutter_data;
2 :
3 : const _offlineAdapterKey = '_offline:keys';
4 :
5 : mixin _RemoteAdapterOffline<T extends DataModel<T>> on _RemoteAdapter<T> {
6 : @override
7 : @mustCallSuper
8 1 : Future<void> onInitialized() async {
9 2 : await super.onInitialized();
10 : // wipe out orphans
11 2 : graph.removeOrphanNodes();
12 : // ensure offline nodes exist
13 2 : if (!graph.hasNode(_offlineAdapterKey)) {
14 2 : graph.addNode(_offlineAdapterKey);
15 : }
16 : }
17 :
18 : @override
19 1 : FutureOr<R?> sendRequest<R>(
20 : final Uri uri, {
21 : DataRequestMethod method = DataRequestMethod.GET,
22 : Map<String, String>? headers,
23 : bool omitDefaultParams = false,
24 : DataRequestType requestType = DataRequestType.adhoc,
25 : String? key,
26 : String? body,
27 : OnRawData<R>? onSuccess,
28 : OnDataError<R>? onError,
29 : }) async {
30 : // default key to type#s3mth1ng
31 2 : final offlineKey = key ?? DataHelpers.generateKey(internalType);
32 : assert(offlineKey.startsWith(internalType));
33 :
34 : // execute request
35 2 : return await super.sendRequest<R>(
36 : uri,
37 : method: method,
38 : headers: headers,
39 : requestType: requestType,
40 : omitDefaultParams: omitDefaultParams,
41 : key: key,
42 : body: body,
43 1 : onSuccess: (data) {
44 : // remove all operations with this
45 : // requestType/offlineKey metadata
46 1 : OfflineOperation<T>(
47 : requestType: requestType,
48 : offlineKey: offlineKey,
49 2 : request: '${method.toShortString()} $uri',
50 : body: body,
51 : headers: headers,
52 : onSuccess: onSuccess,
53 : onError: onError,
54 : adapter: this,
55 1 : ).remove();
56 :
57 : // yield
58 : return onSuccess?.call(data);
59 : },
60 1 : onError: (e) {
61 2 : if (isNetworkError(e.error)) {
62 : // queue a new operation if this is
63 : // a network error and we're offline
64 1 : OfflineOperation<T>(
65 : requestType: requestType,
66 : offlineKey: offlineKey,
67 2 : request: '${method.toShortString()} $uri',
68 : body: body,
69 : headers: headers,
70 : onSuccess: onSuccess,
71 : onError: onError,
72 : adapter: this,
73 1 : ).add();
74 :
75 : // wrap error in an OfflineException
76 2 : e = OfflineException(error: e.error);
77 :
78 : // call error handler but do not return it
79 1 : (onError ?? this.onError).call(e);
80 :
81 : // instead return a fallback model
82 : switch (requestType) {
83 1 : case DataRequestType.findAll:
84 1 : return findAll(remote: false, syncLocal: false) as Future<R>;
85 1 : case DataRequestType.findOne:
86 1 : case DataRequestType.save:
87 : // call without type (ie 3 not users#3)
88 : // key! as we know findOne does pass it
89 2 : return findOne(key!.detypify(), remote: false) as Future<R?>;
90 : default:
91 : return null;
92 : }
93 : }
94 :
95 : // if it was not a network error
96 :
97 : // remove all operations with this
98 : // requestType/offlineKey metadata
99 1 : OfflineOperation<T>(
100 : requestType: requestType,
101 : offlineKey: offlineKey,
102 2 : request: '${method.toShortString()} $uri',
103 : body: body,
104 : headers: headers,
105 : onSuccess: onSuccess,
106 : onError: onError,
107 : adapter: this,
108 1 : ).remove();
109 :
110 : // return handler call
111 1 : return (onError ?? this.onError).call(e);
112 : },
113 : );
114 : }
115 :
116 : /// Determines whether [error] was a network error.
117 1 : @protected
118 : @visibleForTesting
119 : bool isNetworkError(error) {
120 : // timeouts via http's `connectionTimeout` are
121 : // also socket exceptions
122 : // we check the exception like this in order not to import `dart:io`
123 1 : final _err = error.toString();
124 1 : return _err.startsWith('SocketException') ||
125 1 : _err.startsWith('Connection closed before full header was received') ||
126 1 : _err.startsWith('HandshakeException');
127 : }
128 :
129 1 : @protected
130 : @visibleForTesting
131 : @nonVirtual
132 : Set<OfflineOperation<T>> get offlineOperations {
133 2 : final node = graph._getNode(_offlineAdapterKey);
134 3 : return (node ?? {}).entries.where((e) {
135 : // extract type from e.g. _offline:users#4:findOne
136 5 : return e.key.split(':')[1].startsWith(internalType);
137 2 : }).map((e) {
138 : // get first edge value
139 3 : final map = json.decode(e.value.first) as Map<String, dynamic>;
140 1 : return OfflineOperation.fromJson(map, this);
141 1 : }).toSet();
142 : }
143 : }
144 :
145 : /// Represents an offline request that is pending to be retried.
146 : class OfflineOperation<T extends DataModel<T>> with EquatableMixin {
147 : final String offlineKey;
148 : final DataRequestType requestType;
149 : final String request;
150 : final Map<String, String>? headers;
151 : final String? body;
152 : final OnRawData? onSuccess;
153 : final OnDataError? onError;
154 : final _RemoteAdapterOffline<T> adapter;
155 :
156 1 : const OfflineOperation({
157 : required this.offlineKey,
158 : required this.requestType,
159 : required this.request,
160 : this.headers,
161 : this.body,
162 : this.onSuccess,
163 : this.onError,
164 : required this.adapter,
165 : });
166 :
167 : /// Metadata format:
168 : /// _offline:users:d7bcc9a7b72bf90fffd826
169 5 : String get metadata => '_offline:${adapter.internalType}:$hash';
170 :
171 1 : Map<String, dynamic> toJson() {
172 1 : return <String, dynamic>{
173 2 : 't': requestType.toShortString(),
174 1 : 'r': request,
175 1 : 'k': offlineKey,
176 1 : 'b': body,
177 1 : 'h': headers,
178 : };
179 : }
180 :
181 1 : factory OfflineOperation.fromJson(
182 : Map<String, dynamic> json, _RemoteAdapterOffline<T> adapter) {
183 1 : return OfflineOperation(
184 2 : requestType: _getDataRequestType(json['t'] as String),
185 1 : request: json['r'] as String,
186 1 : offlineKey: json['k'] as String,
187 1 : body: json['b'] as String?,
188 : headers:
189 3 : json['h'] == null ? null : Map<String, String>.from(json['h'] as Map),
190 : adapter: adapter,
191 : );
192 : }
193 :
194 1 : Uri get uri {
195 4 : return request.split(' ').last.asUri;
196 : }
197 :
198 1 : DataRequestMethod get method {
199 : return DataRequestMethod.values
200 7 : .singleWhere((m) => m.toShortString() == request.split(' ').first);
201 : }
202 :
203 : /// Adds an edge from the `_offlineAdapterKey` to the `key` for save/delete
204 : /// and stores header/param metadata. Also stores callbacks.
205 1 : void add() {
206 : // DO NOT proceed if operation is in queue
207 3 : if (!adapter.offlineOperations.contains(this)) {
208 2 : final node = json.encode(toJson());
209 :
210 2 : if (adapter._verbose!) {
211 0 : print(
212 0 : '[flutter_data] [${adapter.internalType}] Adding offline operation with metadata: $metadata');
213 0 : print('\n\n');
214 : }
215 :
216 4 : adapter.graph._addEdge(_offlineAdapterKey, node, metadata: metadata);
217 :
218 : // keep callbacks in memory
219 6 : adapter.read(_offlineCallbackProvider)[metadata] ??= [];
220 1 : adapter
221 4 : .read(_offlineCallbackProvider)[metadata]!
222 4 : .add([onSuccess, onError]);
223 : } else {
224 : // trick
225 2 : adapter.graph
226 2 : ._notify([_offlineAdapterKey, ''], DataGraphEventType.addEdge);
227 : }
228 : }
229 :
230 : /// Removes all edges from the `_offlineAdapterKey` for
231 : /// current metadata, as well as callbacks from memory.
232 1 : void remove() {
233 4 : if (adapter.graph._hasEdge(_offlineAdapterKey, metadata: metadata)) {
234 4 : adapter.graph._removeEdges(_offlineAdapterKey, metadata: metadata);
235 2 : if (adapter._verbose!) {
236 0 : print(
237 0 : '[flutter_data] [${adapter.internalType}] Removing offline operation with metadata: $metadata');
238 0 : print('\n\n');
239 : }
240 :
241 5 : adapter.read(_offlineCallbackProvider).remove(metadata);
242 : }
243 : }
244 :
245 1 : Future<void> retry<R>() async {
246 : // look up callbacks (or provide defaults)
247 5 : final fns = adapter.read(_offlineCallbackProvider)[metadata] ??
248 0 : [
249 0 : [null, null]
250 : ];
251 :
252 2 : for (final pair in fns) {
253 3 : await adapter.sendRequest<R>(
254 1 : uri,
255 1 : method: method,
256 1 : headers: headers,
257 1 : requestType: requestType,
258 1 : key: offlineKey,
259 1 : body: body,
260 1 : onSuccess: pair.first as OnRawData<R>?,
261 1 : onError: pair.last as OnDataError<R>?,
262 : );
263 : }
264 : }
265 :
266 : /// This getter ONLY makes sense for `findOne` and `save` operations
267 1 : T? get model {
268 1 : switch (requestType) {
269 1 : case DataRequestType.findOne:
270 0 : return adapter.localAdapter.findOne(adapter.graph
271 0 : .getKeyForId(adapter.internalType, offlineKey.detypify())!);
272 1 : case DataRequestType.save:
273 4 : return adapter.localAdapter.findOne(offlineKey);
274 : default:
275 : return null;
276 : }
277 : }
278 :
279 1 : @override
280 6 : List<Object?> get props => [requestType, request, body, offlineKey, headers];
281 :
282 1 : @override
283 : bool get stringify => true;
284 :
285 : // generates a unique memory-independent hash of this object
286 5 : String get hash => md5.convert(utf8.encode(toString())).toString();
287 : }
288 :
289 : extension OfflineOperationsX on Set<OfflineOperation<DataModel>> {
290 : /// Retries all offline operations for current type.
291 1 : FutureOr<void> retry() async {
292 1 : if (isNotEmpty) {
293 4 : await Future.wait(map((operation) {
294 1 : return operation.retry();
295 : }));
296 : }
297 : }
298 :
299 : /// Removes all offline operations.
300 1 : void reset() {
301 1 : if (isEmpty) {
302 : return;
303 : }
304 2 : final adapter = first.adapter;
305 : // removes node and severs edges
306 2 : final node = adapter.graph._getNode(_offlineAdapterKey);
307 3 : for (final metadata in (node ?? {}).keys.toImmutableList()) {
308 2 : adapter.graph._removeEdges(_offlineAdapterKey, metadata: metadata);
309 : }
310 3 : adapter.read(_offlineCallbackProvider).clear();
311 : }
312 :
313 : /// Filter by [requestType].
314 1 : List<OfflineOperation> only(DataRequestType requestType) {
315 5 : return where((_) => _.requestType == requestType).toImmutableList();
316 : }
317 : }
318 :
319 : // stores onSuccess/onError function combos
320 2 : final _offlineCallbackProvider =
321 3 : StateProvider<Map<String, List<List<Function?>>>>((_) => {});
322 :
323 : /// Every time there is an offline operation added to/
324 : /// removed from the queue, this will notify clients
325 : /// with all pending types (could be none) such that
326 : /// they can implement their own retry strategy.
327 0 : final pendingOfflineTypesProvider =
328 : StateNotifierProvider<DelayedStateNotifier<Set<String>>, Set<String>?>(
329 : (ref) {
330 : final _graph = ref.watch(graphNotifierProvider);
331 :
332 : Set<String> _pendingTypes() {
333 : final node = _graph._getNode(_offlineAdapterKey)!;
334 : // obtain types from metadata e.g. _offline:users#4:findOne
335 : return node.keys.map((m) => m.split(':')[1].split('#')[0]).toSet();
336 : }
337 :
338 : final notifier = DelayedStateNotifier<Set<String>>();
339 : // emit initial value
340 : Timer.run(() {
341 : if (notifier.mounted) {
342 : notifier.state = _pendingTypes();
343 : }
344 : });
345 :
346 : final _dispose = _graph.where((event) {
347 : // filter the right events
348 : return [DataGraphEventType.addEdge, DataGraphEventType.removeEdge]
349 : .contains(event.type) &&
350 : event.keys.length == 2 &&
351 : event.keys.containsFirst(_offlineAdapterKey);
352 : }).addListener((_) {
353 : if (notifier.mounted) {
354 : // recalculate all pending types
355 : notifier.state = _pendingTypes();
356 : }
357 : });
358 :
359 : notifier.onDispose = _dispose;
360 :
361 : return notifier;
362 : });
|