Refactor app version handling and update authentication flow

Previously, this was only triggered when logging in to the application. If a user
just opened the app, it would just stop working. We also now always check this min
version and have removed the option from the android manifest file since disabling
this doesn't make much sense and we have many other platforms as well (iOS, flatpak)
This commit is contained in:
Roland Geider
2025-03-28 17:21:31 +01:00
parent cacb89f8b1
commit e4b550ab52
12 changed files with 161 additions and 107 deletions

View File

@@ -29,10 +29,6 @@
android:enableOnBackInvokedCallback="true"
android:networkSecurityConfig="@xml/network_security_config">
<meta-data
android:name="wger.check_min_app_version"
android:value="true" />
<activity
android:name=".MainActivity"
android:exported="true"

View File

@@ -31,9 +31,6 @@ const DEFAULT_SERVER_TEST = 'https://wger-master.rge.uber.space/';
const TESTSERVER_USER_NAME = 'user';
const TESTSERVER_PASSWORD = 'flutteruser';
/// Keys used in the android manifest
const MANIFEST_KEY_CHECK_UPDATE = 'wger.check_min_app_version';
/// Default impression for a workout session (neutral)
const DEFAULT_IMPRESSION = 2;

View File

@@ -53,6 +53,7 @@ import 'package:wger/screens/routine_list_screen.dart';
import 'package:wger/screens/routine_logs_screen.dart';
import 'package:wger/screens/routine_screen.dart';
import 'package:wger/screens/splash_screen.dart';
import 'package:wger/screens/update_app_screen.dart';
import 'package:wger/screens/weight_screen.dart';
import 'package:wger/theme/theme.dart';
import 'package:wger/widgets/core/about.dart';
@@ -78,11 +79,27 @@ void main() async {
await ServiceLocator().configure();
// Application
runApp(const riverpod.ProviderScope(child: MyApp()));
runApp(const riverpod.ProviderScope(child: MainApp()));
}
class MyApp extends StatelessWidget {
const MyApp();
class MainApp extends StatelessWidget {
const MainApp();
Widget _getHomeScreen(AuthProvider auth) {
if (auth.state == AuthState.loggedIn) {
return HomeTabsScreen();
} else if (auth.state == AuthState.updateRequired) {
return const UpdateAppScreen();
} else {
return FutureBuilder(
future: auth.tryAutoLogin(),
builder: (ctx, authResultSnapshot) =>
authResultSnapshot.connectionState == ConnectionState.waiting
? const SplashScreen()
: const AuthScreen(),
);
}
}
@override
Widget build(BuildContext context) {
@@ -157,15 +174,7 @@ class MyApp extends StatelessWidget {
highContrastTheme: wgerLightThemeHc,
highContrastDarkTheme: wgerDarkThemeHc,
themeMode: user.themeMode,
home: auth.isAuth
? HomeTabsScreen()
: FutureBuilder(
future: auth.tryAutoLogin(),
builder: (ctx, authResultSnapshot) =>
authResultSnapshot.connectionState == ConnectionState.waiting
? const SplashScreen()
: const AuthScreen(),
),
home: _getHomeScreen(auth),
routes: {
DashboardScreen.routeName: (ctx) => const DashboardScreen(),
FormScreen.routeName: (ctx) => const FormScreen(),

View File

@@ -38,6 +38,12 @@ enum LoginActions {
proceed,
}
enum AuthState {
updateRequired,
loggedIn,
loggedOut,
}
class AuthProvider with ChangeNotifier {
final _logger = Logger('AuthProvider');
@@ -46,6 +52,7 @@ class AuthProvider with ChangeNotifier {
String? serverVersion;
PackageInfo? applicationVersion;
Map<String, String> metadata = {};
AuthState state = AuthState.loggedOut;
static const MIN_APP_VERSION_URL = 'min-app-version';
static const SERVER_VERSION_URL = 'version';
@@ -54,7 +61,7 @@ class AuthProvider with ChangeNotifier {
late http.Client client;
AuthProvider([http.Client? client, bool? checkMetadata]) {
AuthProvider([http.Client? client]) {
this.client = client ?? http.Client();
}
@@ -83,23 +90,16 @@ class AuthProvider with ChangeNotifier {
}
/// Checking if there is a new version of the application.
Future<bool> applicationUpdateRequired([
String? version,
Map<String, String>? metadata,
]) async {
metadata ??= this.metadata;
if (!metadata.containsKey(MANIFEST_KEY_CHECK_UPDATE) ||
metadata[MANIFEST_KEY_CHECK_UPDATE] == 'false') {
return false;
}
Future<bool> applicationUpdateRequired([String? version]) async {
final applicationCurrentVersion = version ?? applicationVersion!.version;
final response = await client.get(makeUri(serverUrl!, MIN_APP_VERSION_URL));
final currentVersion = Version.parse(applicationCurrentVersion);
final requiredAppVersion = Version.parse(jsonDecode(response.body));
return requiredAppVersion > currentVersion;
final result = requiredAppVersion > currentVersion;
_logger.fine('Application update required: $result');
return result;
}
/// Registers a new user
@@ -160,15 +160,13 @@ class AuthProvider with ChangeNotifier {
await initVersions(serverUrl);
// If update is required don't log in user
if (await applicationUpdateRequired(
applicationVersion!.version,
{MANIFEST_KEY_CHECK_UPDATE: 'true'},
)) {
if (await applicationUpdateRequired()) {
return {'action': LoginActions.update};
}
// Log user in
token = responseData['token'];
state = AuthState.loggedIn;
notifyListeners();
// store login data in shared preferences
@@ -195,23 +193,58 @@ class AuthProvider with ChangeNotifier {
return userData['serverUrl'] as String;
}
Future<bool> tryAutoLogin() async {
/// Tries to auto-login the user with the stored token
Future<void> tryAutoLogin() async {
final prefs = await SharedPreferences.getInstance();
if (!prefs.containsKey(PREFS_USER)) {
_logger.info('autologin failed');
return false;
_logger.info('autologin failed, no saved user data');
state = AuthState.loggedOut;
return;
}
final extractedUserData = json.decode(prefs.getString(PREFS_USER)!);
token = extractedUserData['token'];
serverUrl = extractedUserData['serverUrl'];
final userData = json.decode(prefs.getString(PREFS_USER)!);
if (!userData.containsKey('token') || !userData.containsKey('serverUrl')) {
_logger.info('autologin failed, no token or serverUrl');
state = AuthState.loggedOut;
return;
}
token = userData['token'];
serverUrl = userData['serverUrl'];
if (token == null || serverUrl == null) {
_logger.info('autologin failed, token or serverUrl is null');
state = AuthState.loggedOut;
return;
}
// // Try to talk to a URL using the token, if this doesn't work, log out
final response = await client.head(
makeUri(serverUrl!, 'routine'),
headers: {
HttpHeaders.contentTypeHeader: 'application/json; charset=UTF-8',
HttpHeaders.userAgentHeader: getAppNameHeader(),
HttpHeaders.authorizationHeader: 'Token $token'
},
);
if (response.statusCode != 200) {
_logger.info('autologin failed, statusCode: ${response.statusCode}');
await logout();
return;
}
await initVersions(serverUrl!);
// If update is required don't log in user
if (await applicationUpdateRequired()) {
state = AuthState.updateRequired;
} else {
state = AuthState.loggedIn;
_logger.info('autologin successful');
}
_logger.info('autologin successful');
setApplicationVersion();
setServerVersion();
notifyListeners();
//_autoLogout();
return true;
}
Future<void> logout({bool shouldNotify = true}) async {
@@ -219,6 +252,7 @@ class AuthProvider with ChangeNotifier {
token = null;
serverUrl = null;
dataInit = false;
state = AuthState.loggedOut;
if (shouldNotify) {
notifyListeners();
@@ -236,7 +270,8 @@ class AuthProvider with ChangeNotifier {
if (applicationVersion != null) {
out = '/${applicationVersion!.version} '
'(${applicationVersion!.packageName}; '
'build: ${applicationVersion!.buildNumber})';
'build: ${applicationVersion!.buildNumber})'
' - https://github.com/wger-project';
}
return 'wger App$out';
}

View File

@@ -89,12 +89,6 @@ class AuthScreen extends StatelessWidget {
),
),
),
// Positioned(
// top: 0.4 * deviceSize.height,
// left: 15,
// right: 15,
// child: const ,
// ),
],
),
);
@@ -166,7 +160,6 @@ class _AuthCardState extends State<AuthCard> {
void _submit(BuildContext context) async {
if (!_formKey.currentState!.validate()) {
// Invalid!
return;
}
_formKey.currentState!.save();

View File

@@ -146,27 +146,8 @@ class _HomeTabsScreenState extends State<HomeTabsScreen> with SingleTickerProvid
future: _initialData,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Center(
child: SizedBox(
height: 70,
child: RiveAnimation.asset(
'assets/animations/wger_logo.riv',
animations: ['idle_loop2'],
),
),
),
Text(
AppLocalizations.of(context).loadingText,
style: Theme.of(context).textTheme.headlineSmall,
),
],
),
),
return const Scaffold(
body: LoadingWidget(),
);
} else {
return Scaffold(
@@ -204,3 +185,33 @@ class _HomeTabsScreenState extends State<HomeTabsScreen> with SingleTickerProvid
);
}
}
class LoadingWidget extends StatelessWidget {
const LoadingWidget({
super.key,
});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Center(
child: SizedBox(
height: 70,
child: RiveAnimation.asset(
'assets/animations/wger_logo.riv',
animations: ['idle_loop2'],
),
),
),
Text(
AppLocalizations.of(context).loadingText,
style: Theme.of(context).textTheme.headlineSmall,
),
],
),
);
}
}

View File

@@ -30,10 +30,7 @@ class UpdateAppScreen extends StatelessWidget {
AppLocalizations.of(context).appUpdateTitle,
style: Theme.of(context).textTheme.headlineSmall,
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [Text(AppLocalizations.of(context).appUpdateContent)],
),
content: Text(AppLocalizations.of(context).appUpdateContent),
actions: null,
),
);

View File

@@ -15,11 +15,9 @@ void main() {
path: 'api/v2/min-app-version/',
);
final testMetadata = {'wger.check_min_app_version': 'true'};
setUp(() {
mockClient = MockClient();
authProvider = AuthProvider(mockClient, false);
authProvider = AuthProvider(mockClient);
authProvider.serverUrl = 'http://localhost';
});
@@ -27,7 +25,7 @@ void main() {
test('app version higher than min version', () async {
// arrange
when(mockClient.get(tVersionUri)).thenAnswer((_) => Future(() => Response('"1.2.0"', 200)));
final updateNeeded = await authProvider.applicationUpdateRequired('1.3.0', testMetadata);
final updateNeeded = await authProvider.applicationUpdateRequired('1.3.0');
// assert
expect(updateNeeded, false);
@@ -36,7 +34,7 @@ void main() {
test('app version higher than min version - 1', () async {
// arrange
when(mockClient.get(tVersionUri)).thenAnswer((_) => Future(() => Response('"1.3"', 200)));
final updateNeeded = await authProvider.applicationUpdateRequired('1.1', testMetadata);
final updateNeeded = await authProvider.applicationUpdateRequired('1.1');
// assert
expect(updateNeeded, true);
@@ -45,7 +43,7 @@ void main() {
test('app version higher than min version - 2', () async {
// arrange
when(mockClient.get(tVersionUri)).thenAnswer((_) => Future(() => Response('"1.3.0"', 200)));
final updateNeeded = await authProvider.applicationUpdateRequired('1.1', testMetadata);
final updateNeeded = await authProvider.applicationUpdateRequired('1.1');
// assert
expect(updateNeeded, true);
@@ -54,7 +52,7 @@ void main() {
test('app version equal as min version', () async {
// arrange
when(mockClient.get(tVersionUri)).thenAnswer((_) => Future(() => Response('"1.3.0"', 200)));
final updateNeeded = await authProvider.applicationUpdateRequired('1.3.0', testMetadata);
final updateNeeded = await authProvider.applicationUpdateRequired('1.3.0');
// assert
expect(updateNeeded, false);

View File

@@ -73,7 +73,7 @@ void main() {
setUp(() {
mockClient = MockClient();
authProvider = AuthProvider(mockClient, false);
authProvider = AuthProvider(mockClient);
authProvider.serverUrl = 'https://wger.de';
SharedPreferences.setMockInitialValues({});

View File

@@ -202,6 +202,18 @@ class MockAuthProvider extends _i1.Mock implements _i2.AuthProvider {
returnValueForMissingStub: null,
);
@override
_i2.AuthState get state => (super.noSuchMethod(
Invocation.getter(#state),
returnValue: _i2.AuthState.updateRequired,
) as _i2.AuthState);
@override
set state(_i2.AuthState? _state) => super.noSuchMethod(
Invocation.setter(#state, _state),
returnValueForMissingStub: null,
);
@override
_i3.Client get client => (super.noSuchMethod(
Invocation.getter(#client),
@@ -253,12 +265,8 @@ class MockAuthProvider extends _i1.Mock implements _i2.AuthProvider {
) as _i5.Future<void>);
@override
_i5.Future<bool> applicationUpdateRequired([
String? version,
Map<String, String>? metadata,
]) =>
(super.noSuchMethod(
Invocation.method(#applicationUpdateRequired, [version, metadata]),
_i5.Future<bool> applicationUpdateRequired([String? version]) => (super.noSuchMethod(
Invocation.method(#applicationUpdateRequired, [version]),
returnValue: _i5.Future<bool>.value(false),
) as _i5.Future<bool>);
@@ -308,10 +316,11 @@ class MockAuthProvider extends _i1.Mock implements _i2.AuthProvider {
) as _i5.Future<String>);
@override
_i5.Future<bool> tryAutoLogin() => (super.noSuchMethod(
_i5.Future<void> tryAutoLogin() => (super.noSuchMethod(
Invocation.method(#tryAutoLogin, []),
returnValue: _i5.Future<bool>.value(false),
) as _i5.Future<bool>);
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
) as _i5.Future<void>);
@override
_i5.Future<void> logout({bool? shouldNotify = true}) => (super.noSuchMethod(

View File

@@ -94,6 +94,18 @@ class MockAuthProvider extends _i1.Mock implements _i3.AuthProvider {
returnValueForMissingStub: null,
);
@override
_i3.AuthState get state => (super.noSuchMethod(
Invocation.getter(#state),
returnValue: _i3.AuthState.updateRequired,
) as _i3.AuthState);
@override
set state(_i3.AuthState? _state) => super.noSuchMethod(
Invocation.setter(#state, _state),
returnValueForMissingStub: null,
);
@override
_i2.Client get client => (super.noSuchMethod(
Invocation.getter(#client),
@@ -145,12 +157,8 @@ class MockAuthProvider extends _i1.Mock implements _i3.AuthProvider {
) as _i5.Future<void>);
@override
_i5.Future<bool> applicationUpdateRequired([
String? version,
Map<String, String>? metadata,
]) =>
(super.noSuchMethod(
Invocation.method(#applicationUpdateRequired, [version, metadata]),
_i5.Future<bool> applicationUpdateRequired([String? version]) => (super.noSuchMethod(
Invocation.method(#applicationUpdateRequired, [version]),
returnValue: _i5.Future<bool>.value(false),
) as _i5.Future<bool>);
@@ -200,10 +208,11 @@ class MockAuthProvider extends _i1.Mock implements _i3.AuthProvider {
) as _i5.Future<String>);
@override
_i5.Future<bool> tryAutoLogin() => (super.noSuchMethod(
_i5.Future<void> tryAutoLogin() => (super.noSuchMethod(
Invocation.method(#tryAutoLogin, []),
returnValue: _i5.Future<bool>.value(false),
) as _i5.Future<bool>);
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
) as _i5.Future<void>);
@override
_i5.Future<void> logout({bool? shouldNotify = true}) => (super.noSuchMethod(

View File

@@ -23,7 +23,7 @@ import 'measurements/measurement_provider_test.mocks.dart';
import 'other/base_provider_test.mocks.dart';
// Test Auth provider
final AuthProvider testAuthProvider = AuthProvider(MockClient(), false)
final AuthProvider testAuthProvider = AuthProvider(MockClient())
..token = 'FooBar'
..serverUrl = 'https://localhost';