fixed stale state after logout and replayed or lost share intents

This commit is contained in:
2026-09-24 21:43:21 +02:00
parent e4e2b1a4fb
commit 498d195138
32 changed files with 743 additions and 188 deletions
+56
View File
@@ -55,6 +55,15 @@ class AccountData {
unawaited(_loadWithRetry());
}
int _sessionEpoch = 0;
/// Bumped on every sign-out. Async work captures it when it starts and
/// drops its result when it changed meanwhile, so a request of the previous
/// account cannot land in the next account's state or cache.
int get sessionEpoch => _sessionEpoch;
bool isCurrentSession(int epoch) => epoch == _sessionEpoch;
String? _username;
String? _password;
String? _appPassword;
@@ -109,6 +118,7 @@ class AccountData {
}
Future<void> removeData() async {
_sessionEpoch++;
_populated = Completer();
_username = null;
_password = null;
@@ -263,6 +273,52 @@ class AccountData {
}
}
bool _isUiEngine = false;
/// Called from `main()`; background entry points never run it.
void markUiEngine() => _isUiEngine = true;
/// Username currently in the keystore. Other engines (widget task, push
/// isolates) sign out or in without this instance noticing.
Future<String?> readStoredUsername() =>
_secureStorage.read(key: _usernameField);
/// Re-reads the session for long-lived background engines: they load once
/// and would otherwise keep acting with an account that signed out in the
/// app meanwhile. Keeps the known state when the keystore is unreadable.
Future<void> reloadFromStorage() async {
// The UI engine performs sign-in and sign-out itself, so its state is
// current; re-reading mid sign-out could resurrect the removed account.
if (_isUiEngine) return;
try {
final username = await _secureStorage.read(key: _usernameField);
final password = await _secureStorage.read(key: _passwordField);
final isDemo = (await _secureStorage.read(key: _demoField)) == 'true';
final usesLoginFlow =
(await _secureStorage.read(key: _loginFlowField)) == 'true';
String? appPassword;
String? appPasswordTalk;
try {
appPassword = await pushSecureStorage.read(key: _appPasswordField);
appPasswordTalk = await pushSecureStorage.read(
key: _appPasswordTalkField,
);
} on Object {
// Group keystore unavailable: fall back to the real password.
}
if (username != _username) _sessionEpoch++;
_username = username;
_password = password;
_isDemo = isDemo;
_usesLoginFlow = usesLoginFlow;
_appPassword = appPassword;
_appPasswordTalk = appPasswordTalk;
if (!_populated.isCompleted) _populated.complete();
} on Object catch (e) {
log('AccountData reload failed, keeping loaded state: $e');
}
}
Future<bool> waitForPopulation() async {
await _populated.future;
return isPopulated();
+152
View File
@@ -0,0 +1,152 @@
import 'dart:async';
import 'dart:developer';
import 'dart:io';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_app_badge/flutter_app_badge.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../api/marianumcloud/talk/share_files_to_chat.dart';
import '../background/widget_background_task.dart';
import '../notification/notification_service.dart';
import '../push/chat_thread_store.dart';
import '../push/nid_store.dart';
import '../push/push_keypair.dart';
import '../push/push_tap_router.dart';
import '../routing/app_routes.dart';
import '../share_intent/share_intent_listener.dart';
import '../state/app/modules/breaker/bloc/breaker_bloc.dart';
import '../state/app/modules/capabilities/bloc/capabilities_cubit.dart';
import '../state/app/modules/chat/bloc/chat_bloc.dart';
import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import '../state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart';
import '../state/app/modules/timetable/bloc/timetable_bloc.dart';
import '../utils/app_paths.dart';
import '../utils/downloads/download_manager.dart';
import '../utils/file_clipboard.dart';
import '../widget/debug/cache_view.dart';
import '../widget_data/widget_sync.dart';
/// Removes everything the signed-out account left on the device. Every step
/// is isolated: one failing step used to skip all later ones and leave the
/// previous account's data behind.
abstract final class SessionWipe {
static Future<void>? _running;
/// Completes once a running wipe finished. A login awaits it, otherwise the
/// wipe could clear what the next account just stored (widget job, caches).
static Future<void> get done => _running ?? Future.value();
/// State that must be gone before the next frame (a login screen can be
/// reached right away): pending navigation and in-memory singletons.
static void clearImmediate() {
_step('share intents', ShareIntentListener.instance.clearAll);
_step('share folder cache', resetTalkShareFolderCache);
_step('pending navigation', () {
AppRoutes.pendingChatToken.value = null;
PushTapRouter.pendingChatToken.value = null;
PushTapRouter.pendingNewsletterId.value = null;
});
_step('file clipboard', FileClipboard.instance.clear);
_step('downloads', DownloadManager.instance.clearAll);
}
static Future<void> run({
required TimetableBloc timetableBloc,
required ChatListBloc chatListBloc,
required ChatBloc chatBloc,
required BreakerBloc breakerBloc,
required CapabilitiesCubit capabilitiesCubit,
required NextcloudCapabilitiesCubit nextcloudCapabilitiesCubit,
}) {
final wipe = _run(
timetableBloc: timetableBloc,
chatListBloc: chatListBloc,
chatBloc: chatBloc,
breakerBloc: breakerBloc,
capabilitiesCubit: capabilitiesCubit,
nextcloudCapabilitiesCubit: nextcloudCapabilitiesCubit,
);
_running = wipe;
return wipe.whenComplete(() {
if (identical(_running, wipe)) _running = null;
});
}
static Future<void> _run({
required TimetableBloc timetableBloc,
required ChatListBloc chatListBloc,
required ChatBloc chatBloc,
required BreakerBloc breakerBloc,
required CapabilitiesCubit capabilitiesCubit,
required NextcloudCapabilitiesCubit nextcloudCapabilitiesCubit,
}) async {
// SettingsCubit is deliberately left alone: its BlocBuilder wraps
// MaterialApp, and emitting a fresh state here tore down the freshly
// mounted Login tree (blank screen until the next interaction).
await Future.wait([
_stepAsync('timetable', timetableBloc.reset),
_stepAsync('chat list', chatListBloc.reset),
_stepAsync('chat', chatBloc.reset),
_stepAsync('breakers', breakerBloc.reset),
_stepAsync('capabilities', capabilitiesCubit.reset),
_stepAsync('nc capabilities', nextcloudCapabilitiesCubit.reset),
]);
await _stepAsync('shared preferences', () async {
await (await SharedPreferences.getInstance()).clear();
});
await _stepAsync('hydrated storage', HydratedBloc.storage.clear);
await _stepAsync('request cache', const CacheView().clear);
await _stepAsync('chat background', () async {
final image = File(AppPaths.chatBackgroundImage);
if (image.existsSync()) await image.delete();
});
await _stepAsync('download files', () async {
final dir = Directory('${(await getTemporaryDirectory()).path}/downloads');
if (dir.existsSync()) await dir.delete(recursive: true);
});
// Push: the tray and its bookkeeping belong to the previous account — a
// tap or inline reply there would otherwise act as the next one. A new
// keypair and FCM token make pushes still addressed to the previous
// registration undecryptable and undeliverable (an offline sign-out
// could not unregister it).
await _stepAsync(
'notification tray',
NotificationService().flutterLocalNotificationsPlugin.cancelAll,
);
await _stepAsync('app badge', () => FlutterAppBadge.count(0));
await _stepAsync('push nid store', NidStore().clear);
await _stepAsync('push thread store', ChatThreadStore().clearAll);
await _stepAsync('push keypair', const PushKeypair().clear);
await _stepAsync('fcm token', FirebaseMessaging.instance.deleteToken);
// Stop the periodic widget refresh job so the background isolate doesn't
// wake up every 30 minutes only to write `loggedIn=false`. Re-registers
// on the next successful login.
await _stepAsync('widget task', WidgetBackgroundTask.cancelAll);
await _stepAsync('widget data', WidgetSync.clear);
await _stepAsync('widget update', WidgetSync.triggerUpdate);
}
static void _step(String name, void Function() step) {
try {
step();
} catch (e, s) {
log('Session wipe step "$name" failed: $e', stackTrace: s);
}
}
static Future<void> _stepAsync(
String name,
Future<void> Function() step,
) async {
try {
await step();
} catch (e, s) {
log('Session wipe step "$name" failed: $e', stackTrace: s);
}
}
}