• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

wger-project / flutter / 30854254088

03 Aug 2026 09:21PM UTC coverage: 40.895% (-15.0%) from 55.93%
30854254088

Pull #1260

github

web-flow
Merge d93794de5 into e3affdd4f
Pull Request #1260: WIP: health sync

9980 of 24404 relevant lines covered (40.89%)

1.72 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

74.12
/lib/core/network/server_gating.dart
1
/*
2
 * This file is part of wger Workout Manager <https://github.com/wger-project>.
3
 * Copyright (c) 2026 - 2026 wger Team
4
 *
5
 * wger Workout Manager is free software: you can redistribute it and/or modify
6
 * it under the terms of the GNU Affero General Public License as published by
7
 * the Free Software Foundation, either version 3 of the License, or
8
 * (at your option) any later version.
9
 *
10
 * This program is distributed in the hope that it will be useful,
11
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13
 * GNU Affero General Public License for more details.
14
 *
15
 * You should have received a copy of the GNU Affero General Public License
16
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
17
 */
18

19
import 'dart:convert';
20
import 'dart:io';
21

22
import 'package:flutter_riverpod/flutter_riverpod.dart';
23
import 'package:http/http.dart' as http;
24
import 'package:logging/logging.dart';
25
import 'package:package_info_plus/package_info_plus.dart';
26
import 'package:version/version.dart';
27
import 'package:wger/core/consts.dart';
28
import 'package:wger/core/errors.dart';
29
import 'package:wger/core/helpers.dart';
30
import 'package:wger/core/network/auth_credentials_storage.dart';
31
import 'package:wger/core/network/auth_notifier.dart';
32
import 'package:wger/core/network/auth_state.dart';
33

34
/// `/api/v2/` endpoints used by the gating chain.
35
const _MIN_APP_VERSION_PATH = 'min-app-version';
36
const _SERVER_VERSION_PATH = 'version';
37

38
/// Server-side reachability and version checks that gate "we have valid
39
/// credentials" from "the user can actually use the app".
40
class ServerGating {
41
  final http.Client _client;
42
  final AuthCredentialsStorage _storage;
43
  final _logger = Logger('ServerGating');
44

45
  ServerGating(this._client, this._storage);
15✔
46

47
  /// Runs the credential-dependent gates: minimum app version, then PowerSync
48
  /// reachability (only on the first-time path). The server-version gate runs
49
  /// separately via [serverVersionGate], so callers check the version once and
50
  /// pass it into the auth state themselves.
51
  Future<AuthStatus> resolve({
3✔
52
    required AuthCredential credential,
53
    required String serverUrl,
54
    required PackageInfo appVersion,
55
  }) async {
56
    if (await applicationUpdateRequired(serverUrl, appVersion.version)) {
6✔
57
      return AuthStatus.appUpdateRequired;
58
    }
59
    if (!await isPowerSyncReachable(serverUrl: serverUrl, credential: credential)) {
3✔
60
      return AuthStatus.powerSyncUnreachable;
61
    }
62
    return AuthStatus.loggedIn;
63
  }
64

65
  /// The single place the server version is fetched and checked. Returns the
66
  /// fetched `version` (for the auth state; null when unreadable) and whether
67
  /// it's `tooOld` for this app. Lenient: `tooOld` is false on an unreadable
68
  /// version.
69
  Future<({String? version, bool tooOld})> serverVersionGate(String serverUrl) async {
5✔
70
    final version = await fetchServerVersion(serverUrl);
5✔
71
    return (version: version, tooOld: serverUpdateRequired(version));
5✔
72
  }
73

74
  /// HEAD probe against `/routine` to confirm the server is reachable
75
  /// and accepts our credential. Returns null when the request couldn't
76
  /// leave the device (offline, TLS handshake failure, etc.) so callers
77
  /// can distinguish "we couldn't reach the server" from "the server said
78
  /// no". Only the latter is grounds for logging the user out.
79
  Future<http.Response?> probe({
2✔
80
    required AuthCredential credential,
81
    required String serverUrl,
82
    required PackageInfo appVersion,
83
  }) async {
84
    try {
85
      return await _client.head(
4✔
86
        makeUri(serverUrl, 'routine'),
2✔
87
        headers: {
2✔
88
          HttpHeaders.contentTypeHeader: 'application/json; charset=UTF-8',
89
          HttpHeaders.userAgentHeader: getAppNameHeader(appVersion),
2✔
90
          HttpHeaders.authorizationHeader: credential.authHeaderValue,
2✔
91
        },
92
      );
93
    } on Exception catch (e, s) {
×
94
      if (isNetworkError(e)) {
×
95
        _logger.warning('wger probe: server unreachable: $e', e, s);
×
96
        return null;
97
      }
98
      rethrow;
99
    }
100
  }
101

102
  /// Detects reverse-proxy misconfiguration on the wger server by
103
  /// confirming pagination URLs returned by the API point back to the
104
  /// same host and scheme as the configured [serverUrl]. Returns true
105
  /// when the configuration looks fine (or the check could not be
106
  /// completed conclusively, so the caller defaults to permissive).
107
  Future<bool> serverConfigSane({
1✔
108
    required String serverUrl,
109
    required AuthCredential credential,
110
  }) async {
111
    try {
112
      final baseUri = Uri.parse(serverUrl);
1✔
113
      final response = await _client.get(
2✔
114
        Uri.parse('$serverUrl/api/v2/exercise/?limit=1'),
2✔
115
        headers: {
1✔
116
          HttpHeaders.authorizationHeader: credential.authHeaderValue,
1✔
117
          HttpHeaders.acceptHeader: 'application/json',
118
        },
119
      );
120

121
      if (response.statusCode != 200) {
2✔
122
        return true;
123
      }
124

125
      final data = json.decode(response.body) as Map<String, dynamic>;
2✔
126
      final nextUrl = data['next'] as String?;
1✔
127
      if (nextUrl == null) {
128
        return true;
129
      }
130

131
      final nextUri = Uri.parse(nextUrl);
×
132
      return nextUri.host.toLowerCase() == baseUri.host.toLowerCase() &&
×
133
          nextUri.scheme == baseUri.scheme;
×
134
    } catch (e) {
135
      _logger.info('serverConfigSane check failed: $e');
×
136
      return true;
137
    }
138
  }
139

140
  /// Tries to reach the PowerSync service and returns true if it looks
141
  /// alive. Only runs the first time a user logs in (subsequent calls
142
  /// short-circuit via [AuthCredentialsStorage.hasEverSynced]); on
143
  /// success the flag is set so future starts skip the probe.
144
  Future<bool> isPowerSyncReachable({
4✔
145
    required String serverUrl,
146
    required AuthCredential credential,
147
  }) async {
148
    if (await _storage.hasEverSynced()) {
8✔
149
      return true;
150
    }
151

152
    try {
153
      final tokenResponse = await _client.get(
8✔
154
        makeUri(serverUrl, 'powersync-token', trailingSlash: false),
4✔
155
        headers: {
4✔
156
          HttpHeaders.contentTypeHeader: 'application/json',
157
          HttpHeaders.authorizationHeader: credential.authHeaderValue,
4✔
158
        },
159
      );
160
      if (tokenResponse.statusCode != 200) {
8✔
161
        _logger.warning(
×
162
          'PowerSync probe: token endpoint returned ${tokenResponse.statusCode}',
×
163
        );
164
        return false;
165
      }
166
      final body = json.decode(tokenResponse.body) as Map<String, dynamic>;
8✔
167
      final providedUrl = body['powersync_url'] as String?;
4✔
168
      final powerSyncUrl = await findLivePowerSyncUrl(
4✔
169
        client: _client,
4✔
170
        serverUrl: serverUrl,
171
        provided: providedUrl,
172
      );
173
      if (powerSyncUrl == null) {
174
        _logger.warning(
3✔
175
          'PowerSync probe: no endpoint answered the liveness probe '
176
          '(server-provided powersync_url: "$providedUrl")',
177
        );
178
        return false;
179
      }
180
      if (powerSyncUrl != providedUrl) {
3✔
181
        _logger.warning(
3✔
182
          'PowerSync probe: server-provided powersync_url "$providedUrl" is '
183
          'unreachable, using $powerSyncUrl instead',
184
        );
185
      }
186
      await _storage.markEverSynced();
6✔
187
      return true;
188
    } on Exception catch (e, s) {
1✔
189
      _logger.warning('PowerSync probe failed: $e', e, s);
×
190
      return false;
191
    }
192
  }
193

194
  /// Fetches the server's reported version, or null when it can't be read
195
  /// (non-200, unparseable body, or network error). Null is handled leniently
196
  /// by [serverUpdateRequired], so a transient blip doesn't gate the user out.
197
  Future<String?> fetchServerVersion(String serverUrl) async {
5✔
198
    try {
199
      final response = await _client.get(makeUri(serverUrl, _SERVER_VERSION_PATH));
15✔
200
      if (response.statusCode != 200) {
10✔
201
        _logger.warning('fetchServerVersion: status ${response.statusCode}, skipping check');
×
202
        return null;
203
      }
204
      final decoded = json.decode(response.body);
10✔
205
      return decoded is String ? decoded : null;
5✔
206
    } on Exception catch (e, s) {
×
207
      _logger.warning('fetchServerVersion failed: $e', e, s);
×
208
      return null;
209
    }
210
  }
211

212
  /// Whether the server requires a newer app build. Lenient: on a non-200,
213
  /// unparseable body, or network error the check is skipped (returns false),
214
  /// so a transient blip doesn't lock the user out.
215
  Future<bool> applicationUpdateRequired(String serverUrl, String appVersion) async {
4✔
216
    try {
217
      final response = await _client.get(makeUri(serverUrl, _MIN_APP_VERSION_PATH));
12✔
218
      if (response.statusCode != 200) {
8✔
219
        _logger.warning(
×
220
          'applicationUpdateRequired: status ${response.statusCode}, skipping check',
×
221
        );
222
        return false;
223
      }
224
      final decoded = json.decode(response.body);
8✔
225
      if (decoded is! String) {
4✔
226
        _logger.warning('applicationUpdateRequired: unexpected body, skipping check');
×
227
        return false;
228
      }
229
      final current = Version.parse(appVersion);
4✔
230
      final required = Version.parse(decoded);
4✔
231
      final needUpdate = required > current;
4✔
232
      if (needUpdate) {
233
        _logger.fine('Application update required: $required > $current');
3✔
234
      }
235
      return needUpdate;
236
    } on Exception catch (e, s) {
×
237
      _logger.warning('applicationUpdateRequired failed: $e', e, s);
×
238
      return false;
239
    }
240
  }
241
}
242

243
/// Checks whether the connected server meets the minimum version required
244
/// by this build of the app.
245
///
246
/// Returns false (lenient) when the version cannot be read or parsed, so
247
/// users aren't locked out on unexpected server configurations.
248
bool serverUpdateRequired(String? rawVersion) {
5✔
249
  final logger = Logger('ServerGating');
5✔
250
  if (rawVersion == null) {
251
    logger.warning('serverUpdateRequired: serverVersion is null, skipping check');
×
252
    return false;
253
  }
254

255
  // Strip common non-semver suffixes emitted by Python/Django backends,
256
  // e.g. '2.5.0a2' → '2.5.0', '2.3.0 (git-abc1234)' → '2.3.0'.
257
  final sanitized = rawVersion
258
      .replaceFirst(RegExp(r'\s.*$'), '')
10✔
259
      .replaceFirst(RegExp(r'[a-zA-Z].*$'), '');
10✔
260

261
  final Version current;
262
  try {
263
    current = Version.parse(sanitized);
5✔
264
  } on FormatException {
×
265
    logger.warning(
×
266
      'serverUpdateRequired: could not parse server version "$rawVersion" '
267
      '(sanitized: "$sanitized"), skipping check',
268
    );
269
    return false;
270
  }
271
  final required = Version.parse(MIN_SERVER_VERSION);
5✔
272
  final needUpdate = current < required;
5✔
273
  if (needUpdate) {
274
    logger.fine('Server update required: server $current < minimum $required');
×
275
  }
276
  return needUpdate;
277
}
278

279
/// Provider over the singleton gating service. Uses the raw HTTP client
280
/// from [authHttpClientProvider] (probes are unauthenticated or carry the
281
/// credential explicitly, so the auth-injecting wrapper is the wrong
282
/// dependency here).
283
final serverGatingProvider = Provider<ServerGating>(
42✔
284
  (ref) => ServerGating(
28✔
285
    ref.read(authHttpClientProvider),
28✔
286
    ref.read(authCredentialsStorageProvider),
28✔
287
  ),
288
);
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc