Skip to content

Commit

Permalink
Invalid session refresh (#114)
Browse files Browse the repository at this point in the history
* detect invalidated sessions and add a workflow to renew them

* remove duplicated error reporting

* simplify error management when using wallabag's API

* rework the login form to handle fields prefilling better
  • Loading branch information
casimir authored Jan 1, 2024
1 parent 098093e commit de2bc98
Show file tree
Hide file tree
Showing 7 changed files with 104 additions and 67 deletions.
8 changes: 8 additions & 0 deletions lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,14 @@
"@session_neverSynced": {
"description": "Default value for the last sync time."
},
"session_renewDialogMessage": "The server refuses the current session token. You will need to log in again to resume the synchronization.",
"@session_renewDialogMessage": {
"description": "The message of the confirmation dialog to renew the session."
},
"session_renewDialogTitle": "Renew session",
"@session_renewDialogTitle": {
"description": "The title of the confirmation dialog to renew the session."
},
"session_title": "Session details",
"@session_title": {
"description": "The title of the session details screen."
Expand Down
6 changes: 0 additions & 6 deletions lib/pages/listing.dart
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,6 @@ class _ListingPageState extends State<ListingPage> {
final settings = storage.settings;
final queryProvider = context.watch<QueryProvider>();

storage.onError = (error) {
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
SnackBar(content: Text(error.toString())),
);
};

Future<void> doRefresh() async {
_log.info('triggered refresh');
await context.read<RemoteSyncer>().synchronize(withFinalRefresh: true);
Expand Down
27 changes: 20 additions & 7 deletions lib/pages/login.dart
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ class LoginPage extends StatefulWidget {

final Map<String, String>? initial;

bool get hasInitialData => initial != null && initial!.isNotEmpty;

@override
State<LoginPage> createState() => _LoginPageState();
}
Expand All @@ -35,7 +37,18 @@ class _LoginPageState extends State<LoginPage> {
@override
void initState() {
super.initState();
_initialData = widget.initial;

final wallabag = WallabagInstance.get();
if (!widget.hasInitialData && wallabag.hasCredentials) {
_initialData = {
'server': wallabag.credentials.server.toString(),
'clientId': wallabag.credentials.clientId,
'clientSecret': wallabag.credentials.clientSecret,
};
} else {
_initialData = widget.initial;
}

if (WallabagInstance.isReady) {
WidgetsBinding.instance.addPostFrameCallback((_) async {
final settings = context.read<SettingsProvider>();
Expand All @@ -61,7 +74,7 @@ class _LoginPageState extends State<LoginPage> {

@override
Widget build(BuildContext context) {
if (_initialData != widget.initial) {
if (widget.hasInitialData && _initialData != widget.initial) {
// when a deeplink is opened and the login page is already shown
_initialData = widget.initial;
if (_initialData?['server'] != null) {
Expand All @@ -85,7 +98,7 @@ class _LoginPageState extends State<LoginPage> {
_configuredServer = check;
}
}),
initial: widget.initial?['server'],
initial: _initialData?['server'],
),
Padding(
padding: const EdgeInsets.all(8.0),
Expand All @@ -108,7 +121,7 @@ class _LoginPageState extends State<LoginPage> {
icon: const Icon(Icons.key),
labelText: context.L.login_fieldClientId,
),
initialValue: widget.initial?['clientId'],
initialValue: _initialData?['clientId'],
),
FormBuilderTextField(
name: 'clientSecret',
Expand All @@ -118,7 +131,7 @@ class _LoginPageState extends State<LoginPage> {
icon: const Icon(Icons.key),
labelText: context.L.login_fieldClientSecret,
),
initialValue: widget.initial?['clientSecret'],
initialValue: _initialData?['clientSecret'],
),
FormBuilderTextField(
name: 'username',
Expand All @@ -129,7 +142,7 @@ class _LoginPageState extends State<LoginPage> {
icon: const Icon(Icons.person),
labelText: context.L.login_fieldUsername,
),
initialValue: widget.initial?['username'],
initialValue: _initialData?['username'],
),
FormBuilderTextField(
name: 'password',
Expand All @@ -141,7 +154,7 @@ class _LoginPageState extends State<LoginPage> {
icon: const Icon(Icons.password),
labelText: context.L.login_fieldPassword,
),
initialValue: widget.initial?['password'],
initialValue: _initialData?['password'],
),
const SizedBox(height: 8.0),
]),
Expand Down
2 changes: 1 addition & 1 deletion lib/pages/session_details.dart
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ class SessionDetailsPage extends StatelessWidget {
);
if (result == OkCancelResult.cancel) return;

await WallabagInstance.get().resetTokenData();
await WallabagInstance.get().resetSession();
settings.clear();
await DB.clear();
if (context.mounted) {
Expand Down
72 changes: 31 additions & 41 deletions lib/services/wallabag_storage.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import '../wallabag/wallabag.dart';
final _log = Logger('wallabag.storage');

class WallabagStorage with ChangeNotifier {
WallabagStorage(this.settings, {this.onError}) {
WallabagStorage(this.settings) {
_watcher = db.articles.watchLazy().listen((_) => notifyListeners());

// ensure a relative freshness of the articles
Expand All @@ -28,7 +28,6 @@ class WallabagStorage with ChangeNotifier {
final WallabagClient wallabag = WallabagInstance.get();
StreamSubscription? _watcher;
final SettingsProvider settings;
void Function(Exception)? onError;

@override
void dispose() {
Expand Down Expand Up @@ -124,11 +123,7 @@ class WallabagStorage with ChangeNotifier {
perPage: 100,
detail: DetailValue.metadata,
);
await for (final (entries, err) in entriesStream) {
if (err != null) {
onError?.call(err);
break;
}
await for (final entries in entriesStream) {
localIds = localIds.difference(entries.map((e) => e.id).toSet());
}

Expand Down Expand Up @@ -174,42 +169,37 @@ class WallabagStorage with ChangeNotifier {

if (since == null) clearArticles();

try {
final stopwatch = Stopwatch()..start();
var entriesStream =
wallabag.fetchAllEntries(since: since, onProgress: onProgress);
await for (final (entries, err) in entriesStream) {
if (err != null) throw err;
final articles = {
for (var e in entries) e.id: Article.fromWallabagEntry(e)
};
final positions =
await db.articleScrollPositions.getAll(articles.keys.toList());
final invalidPositions = positions
.whereType<ArticleScrollPosition>()
.where((e) => e.readingTime != articles[e.id]?.readingTime)
.map((e) => e.id!)
.toList();

final putCount = await db.writeTxn(() async {
final res = await db.articles.putAll(articles.values.toList());
await db.articleScrollPositions.deleteAll(invalidPositions);
return res.length;
});
_log.info('saved $putCount entries to the database');

count += entries.length;
}
_log.info(
'completed refresh of $count entries in ${stopwatch.elapsed.inSeconds} s');
final stopwatch = Stopwatch()..start();
var entriesStream =
wallabag.fetchAllEntries(since: since, onProgress: onProgress);
await for (final entries in entriesStream) {
final articles = {
for (var e in entries) e.id: Article.fromWallabagEntry(e)
};
final positions =
await db.articleScrollPositions.getAll(articles.keys.toList());
final invalidPositions = positions
.whereType<ArticleScrollPosition>()
.where((e) => e.readingTime != articles[e.id]?.readingTime)
.map((e) => e.id!)
.toList();

final putCount = await db.writeTxn(() async {
final res = await db.articles.putAll(articles.values.toList());
await db.articleScrollPositions.deleteAll(invalidPositions);
return res.length;
});
_log.info('saved $putCount entries to the database');

count += entries.length;
}
_log.info(
'completed refresh of $count entries in ${stopwatch.elapsed.inSeconds} s');

final now = DateTime.now().millisecondsSinceEpoch / 1000;
settings[Sk.lastRefresh] = now.toInt();
final now = DateTime.now().millisecondsSinceEpoch / 1000;
settings[Sk.lastRefresh] = now.toInt();

_syncRemoteDeletes();
} on Exception catch (e) {
onError?.call(e);
}
_syncRemoteDeletes();

updateAppBadge();

Expand Down
31 changes: 22 additions & 9 deletions lib/wallabag/wallabag.dart
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,16 @@ class WallabagError implements Exception {
}
factory WallabagError.fromException(Exception e, {http.Response? response}) =>
WallabagError('unknown error', source: e, response: response);

bool get isInvalidTokenError {
if (response?.body == null) return false;
try {
final json = jsonDecode(response!.body);
return json['error'] == 'invalid_grant';
} catch (_) {
return false;
}
}
}

typedef Decoder<T> = T Function(Map<String, dynamic>);
Expand Down Expand Up @@ -93,6 +103,7 @@ class WallabagClient extends http.BaseClient {
final CredentialsManager _credsManager;
final String? userAgent;

bool get hasCredentials => _credsManager.credentials != null;
Credentials get credentials => _credsManager.credentials!;
bool get canRefreshToken => _credsManager.canRefreshToken;
bool get tokenIsExpired => _credsManager.tokenIsExpired;
Expand Down Expand Up @@ -151,6 +162,8 @@ class WallabagClient extends http.BaseClient {
_credsManager.token = null;
}

Future<void> resetSession() => _credsManager.clear();

Future<http.Response> fetchToken(String username, String password) {
return authenticate({
'grant_type': 'password',
Expand Down Expand Up @@ -280,7 +293,7 @@ class WallabagClient extends http.BaseClient {
return data.total;
}

Stream<(List<WallabagEntry>, WallabagError?)> fetchAllEntries({
Stream<List<WallabagEntry>> fetchAllEntries({
int? archive,
int? starred,
SortValue? sort,
Expand Down Expand Up @@ -313,21 +326,21 @@ class WallabagClient extends http.BaseClient {
domainName: domainName,
);
} catch (source, st) {
final e = switch (source.runtimeType) {
WallabagError e => e,
Exception e => WallabagError.fromException(e),
_ => WallabagError.fromException(Exception(source.toString())),
};
_log.severe(
'error fetching entries (page $pageIndex)', source.toString(), st);
yield ([], e);
return;
if (source is WallabagError) {
rethrow;
} else if (source is Exception) {
throw WallabagError.fromException(source);
} else {
throw WallabagError.fromException(Exception(source.toString()));
}
}
lastPage = pageData.pages;
_log.info('fetched entries (page $pageIndex of $lastPage)');
onProgress?.call(pageIndex / lastPage);
pageIndex++;
yield (pageData.embedded.items, null);
yield pageData.embedded.items;
}
}
}
Expand Down
25 changes: 22 additions & 3 deletions lib/widgets/remote_sync_progress_indicator.dart
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import 'package:adaptive_dialog/adaptive_dialog.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:logging/logging.dart';
import 'package:provider/provider.dart';

import '../buildcontext_extension.dart';
import '../services/remote_sync.dart';
import '../wallabag/wallabag.dart';

final _log = Logger('widgets.remote_sync_progress_reporter');

Expand All @@ -18,9 +22,24 @@ class RemoteSyncProgressIndicator extends StatelessWidget {

final error = provider?.lastError;
if (error != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
final snackbar = SnackBar(content: Text(error.toString()));
ScaffoldMessenger.maybeOf(context)?.showSnackBar(snackbar);
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (error is WallabagError && error.isInvalidTokenError) {
final result = await showOkCancelAlertDialog(
context: context,
title: context.L.session_renewDialogTitle,
message: context.L.session_renewDialogMessage,
okLabel: context.L.login_actionLogin,
);
if (result == OkCancelResult.ok) {
await WallabagInstance.get().resetTokenData();
if (context.mounted) {
context.go('/login');
}
}
} else {
final snackbar = SnackBar(content: Text(error.toString()));
ScaffoldMessenger.maybeOf(context)?.showSnackBar(snackbar);
}
});
}

Expand Down

0 comments on commit de2bc98

Please sign in to comment.