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 1 : 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 2 : (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 2 : 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 : Set<OfflineOperation<T>> get offlineOperations {
132 2 : final node = graph._getNode(_offlineAdapterKey);
133 3 : return (node ?? {}).entries.where((e) {
134 : // extract type from e.g. _offline:users#4:findOne
135 5 : return e.key.split(':')[1].startsWith(internalType);
136 2 : }).map((e) {
137 : // get first edge value
138 3 : final map = json.decode(e.value.first) as Map<String, dynamic>;
139 1 : return OfflineOperation.fromJson(map, this);
140 1 : }).toSet();
141 : }
142 : }
143 :
144 : /// Represents an offline request that is pending to be retried.
145 : class OfflineOperation<T extends DataModel<T>> with EquatableMixin {
146 : final String offlineKey;
147 : final DataRequestType requestType;
148 : final String request;
149 : final Map<String, String>? headers;
150 : final String? body;
151 : final OnRawData? onSuccess;
152 : final OnDataError? onError;
153 : final _RemoteAdapterOffline<T> adapter;
154 :
155 1 : const OfflineOperation({
156 : required this.offlineKey,
157 : required this.requestType,
158 : required this.request,
159 : this.headers,
160 : this.body,
161 : this.onSuccess,
162 : this.onError,
163 : required this.adapter,
164 : });
165 :
166 : /// Metadata format:
167 : /// _offline:users:d7bcc9a7b72bf90fffd826
168 5 : String get metadata => '_offline:${adapter.internalType}:$hash';
169 :
170 1 : Map<String, dynamic> toJson() {
171 1 : return <String, dynamic>{
172 2 : 't': requestType.toShortString(),
173 1 : 'r': request,
174 1 : 'k': offlineKey,
175 1 : 'b': body,
176 1 : 'h': headers,
177 : };
178 : }
179 :
180 1 : factory OfflineOperation.fromJson(
181 : Map<String, dynamic> json, _RemoteAdapterOffline<T> adapter) {
182 1 : return OfflineOperation(
183 2 : requestType: _getDataRequestType(json['t'] as String),
184 1 : request: json['r'] as String,
185 1 : offlineKey: json['k'] as String,
186 1 : body: json['b'] as String?,
187 : headers:
188 3 : json['h'] == null ? null : Map<String, String>.from(json['h'] as Map),
189 : adapter: adapter,
190 : );
191 : }
192 :
193 1 : Uri get uri {
194 4 : return request.split(' ').last.asUri;
195 : }
196 :
197 1 : DataRequestMethod get method {
198 : return DataRequestMethod.values
199 7 : .singleWhere((m) => m.toShortString() == request.split(' ').first);
200 : }
201 :
202 : /// Adds an edge from the `_offlineAdapterKey` to the `key` for save/delete
203 : /// and stores header/param metadata. Also stores callbacks.
204 1 : void add() {
205 : // DO NOT proceed if operation is in queue
206 3 : if (!adapter.offlineOperations.contains(this)) {
207 2 : final node = json.encode(toJson());
208 :
209 2 : if (adapter._verbose) {
210 0 : print(
211 0 : '[flutter_data] [${adapter.internalType}] Adding offline operation with metadata: $metadata');
212 0 : print('\n\n');
213 : }
214 :
215 4 : adapter.graph._addEdge(_offlineAdapterKey, node, metadata: metadata);
216 :
217 : // keep callbacks in memory
218 8 : adapter.ref.read(_offlineCallbackProvider).state[metadata] ??= [];
219 2 : adapter.ref
220 2 : .read(_offlineCallbackProvider)
221 3 : .state[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 7 : adapter.ref.read(_offlineCallbackProvider).state.remove(metadata);
242 : }
243 : }
244 :
245 1 : Future<void> retry<R>() async {
246 : // look up callbacks (or provide defaults)
247 7 : final fns = adapter.ref.read(_offlineCallbackProvider).state[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 5 : adapter.ref.read(_offlineCallbackProvider).state.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<ValueStateNotifier<Set<String>>, Set<String>>((ref) {
329 : final _graph = ref.read(graphNotifierProvider);
330 :
331 : Set<String> _pendingTypes() {
332 : final node = _graph._getNode(_offlineAdapterKey)!;
333 : // obtain types from metadata e.g. _offline:users#4:findOne
334 : return node.keys.map((m) => m.split(':')[1].split('#')[0]).toSet();
335 : }
336 :
337 : final notifier = ValueStateNotifier(<String>{});
338 : // emit initial value
339 : Timer.run(() {
340 : if (notifier.mounted) {
341 : notifier.value = _pendingTypes();
342 : }
343 : });
344 :
345 : final _dispose = _graph.where((event) {
346 : // filter the right events
347 : return [DataGraphEventType.addEdge, DataGraphEventType.removeEdge]
348 : .contains(event.type) &&
349 : event.keys.length == 2 &&
350 : event.keys.containsFirst(_offlineAdapterKey);
351 : }).addListener((_) {
352 : if (notifier.mounted) {
353 : // recalculate all pending types
354 : notifier.value = _pendingTypes();
355 : }
356 : });
357 :
358 : notifier.onDispose = _dispose;
359 :
360 : return notifier;
361 : });
|