diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 3800d0c..123c275 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -9,7 +9,7 @@ result.notImplemented() } } + MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + shareChannel + ).setMethodCallHandler { call, result -> + when (call.method) { + "listenerReady" -> { + shareListenerReady = true + bufferedShare?.let { flutterEngine.activityControlSurface.onNewIntent(it) } + bufferedShare = null + result.success(null) + } + else -> result.notImplemented() + } + } consumeIntentData(intent) } override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) + if (!shareListenerReady && intent.action in shareActions) { + bufferedShare = intent + } consumeIntentData(intent) } + private fun isWidgetTap(intent: Intent?): Boolean = + intent?.getBooleanExtra("widget_open_timetable", false) == true + private fun consumeIntentData(intent: Intent?) { - if (intent?.getBooleanExtra("widget_open_timetable", false) == true) { - pendingTimetableTap = true - } + if (isWidgetTap(intent)) pendingTimetableTap = true } } diff --git a/ios/Runner/SceneDelegate.swift b/ios/Runner/SceneDelegate.swift index eb61b78..6c8a831 100644 --- a/ios/Runner/SceneDelegate.swift +++ b/ios/Runner/SceneDelegate.swift @@ -13,21 +13,44 @@ class SceneDelegate: FlutterSceneDelegate { options connectionOptions: UIScene.ConnectionOptions ) { super.scene(scene, willConnectTo: session, options: connectionOptions) - for context in connectionOptions.urlContexts { + var handledShare = false + for context in connectionOptions.urlContexts where isShareUrl(context.url) { _ = ReceiveSharingIntentPlugin.instance.application( UIApplication.shared, didFinishLaunchingWithOptions: [UIApplication.LaunchOptionsKey.url: context.url] ) + handledShare = true } + if handledShare { clearSharedPayload() } } override func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { - for context in URLContexts { + let shares = URLContexts.filter { isShareUrl($0.url) } + for context in shares { _ = ReceiveSharingIntentPlugin.instance.application( UIApplication.shared, open: context.url, options: [:] ) } + if !shares.isEmpty { clearSharedPayload() } + let others = URLContexts.subtracting(shares) + if !others.isEmpty { super.scene(scene, openURLContexts: others) } + } + + private func isShareUrl(_ url: URL) -> Bool { + ReceiveSharingIntentPlugin.instance.hasMatchingSchemePrefix(url: url) + } + + // The plugin keeps the payload in memory once read but never removes it + // from the App Group defaults. The Share Extension opens the app twice per + // share and a scene reconnect replays the URL, so every later delivery + // would publish the finished share again. + private func clearSharedPayload() { + let groupId = Bundle.main.object(forInfoDictionaryKey: "AppGroupId") as? String + let defaults = UserDefaults( + suiteName: groupId ?? "group.\(Bundle.main.bundleIdentifier ?? "")") + defaults?.removeObject(forKey: "ShareKey") + defaults?.removeObject(forKey: "ShareMessageKey") } } diff --git a/ios/Share Extension/ShareViewController.swift b/ios/Share Extension/ShareViewController.swift index 881b42f..a54b551 100644 --- a/ios/Share Extension/ShareViewController.swift +++ b/ios/Share Extension/ShareViewController.swift @@ -152,17 +152,54 @@ final class ShareViewController: UIViewController { } } - private func copyIntoAppGroup(src: URL) -> URL? { + // loadItem-Callbacks laufen parallel; Ordneranlage und Namenswahl seriell. + private let fileQueue = DispatchQueue(label: "share.files") + + // Ein Ordner pro Share: Gleichnamige Dateien (z. B. bearbeitete Fotos, alle + // "FullSizeRender.jpg") überschrieben sich sonst gegenseitig, auch über + // zwei Shares hinweg. Die App löscht den Ordner, sobald der Share erledigt ist. + private lazy var shareDir: URL? = { guard let container = FileManager.default .containerURL(forSecurityApplicationGroupIdentifier: appGroupId) else { return nil } - let name = src.lastPathComponent.isEmpty ? UUID().uuidString : src.lastPathComponent - let dst = container.appendingPathComponent(name) + let root = container.appendingPathComponent("share_intent", isDirectory: true) + sweepStaleShares(in: root) + let dir = root.appendingPathComponent(UUID().uuidString, isDirectory: true) + do { + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } catch { + return nil + } + }() + + // Shares, deren App-Flow nie abgeschlossen wurde (Prozess beendet o. ä.), + // räumt sonst niemand weg — der App-Group-Container wird nie geleert. + private func sweepStaleShares(in root: URL) { + let fm = FileManager.default + guard let entries = try? fm.contentsOfDirectory( + at: root, includingPropertiesForKeys: [.creationDateKey]) else { return } + let cutoff = Date().addingTimeInterval(-24 * 60 * 60) + for entry in entries { + let created = (try? entry.resourceValues(forKeys: [.creationDateKey]))?.creationDate + if let created, created < cutoff { try? fm.removeItem(at: entry) } + } + } + + private func copyIntoAppGroup(src: URL) -> URL? { + fileQueue.sync { copyIntoShareDir(src: src) } + } + + private func copyIntoShareDir(src: URL) -> URL? { + guard let dir = shareDir else { return nil } + let name = src.lastPathComponent.isEmpty ? UUID().uuidString : src.lastPathComponent + var dst = dir.appendingPathComponent(name) + // Mehrfachauswahl mit gleichem Namen innerhalb eines Shares. + if FileManager.default.fileExists(atPath: dst.path) { + dst = dir.appendingPathComponent("\(UUID().uuidString.prefix(8))-\(name)") + } do { - if FileManager.default.fileExists(atPath: dst.path) { - try FileManager.default.removeItem(at: dst) - } try FileManager.default.copyItem(at: src, to: dst) return dst } catch { @@ -171,10 +208,9 @@ final class ShareViewController: UIViewController { } private func writePng(image: UIImage) -> URL? { - guard let container = FileManager.default - .containerURL(forSecurityApplicationGroupIdentifier: appGroupId), - let data = image.pngData() else { return nil } - let dst = container.appendingPathComponent("\(UUID().uuidString).png") + guard let data = image.pngData(), + let dir = fileQueue.sync(execute: { shareDir }) else { return nil } + let dst = dir.appendingPathComponent("\(UUID().uuidString).png") return (try? data.write(to: dst)) != nil ? dst : nil } diff --git a/lib/api/errors/stale_session_exception.dart b/lib/api/errors/stale_session_exception.dart new file mode 100644 index 0000000..52936e1 --- /dev/null +++ b/lib/api/errors/stale_session_exception.dart @@ -0,0 +1,11 @@ +import 'app_exception.dart'; + +/// A request finished after the account it was started for signed out. Its +/// result must not reach the state or cache of whoever is signed in now. +class StaleSessionException extends AppException { + const StaleSessionException() + : super( + userMessage: 'Die Anmeldung hat sich geändert. Bitte lade neu.', + technicalDetails: 'result of a previous session discarded', + ); +} diff --git a/lib/api/marianumcloud/talk/share_files_to_chat.dart b/lib/api/marianumcloud/talk/share_files_to_chat.dart index 9be0faa..5a7facb 100644 --- a/lib/api/marianumcloud/talk/share_files_to_chat.dart +++ b/lib/api/marianumcloud/talk/share_files_to_chat.dart @@ -17,6 +17,10 @@ Future? _shareFolderReady; Future ensureTalkShareFolder() => _shareFolderReady ??= _createTalkShareFolder(); +/// The folder is per user; after a sign-out the next account has to check +/// again. +void resetTalkShareFolderCache() => _shareFolderReady = null; + Future _createTalkShareFolder() async { try { final webdav = await WebdavApi.webdav; diff --git a/lib/api/marianumcloud/webdav/webdav_api.dart b/lib/api/marianumcloud/webdav/webdav_api.dart index 942b560..5395706 100644 --- a/lib/api/marianumcloud/webdav/webdav_api.dart +++ b/lib/api/marianumcloud/webdav/webdav_api.dart @@ -18,7 +18,10 @@ abstract class WebdavApi { /// changes (app password minted/renewed, account switch) so it never keeps /// authenticating with stale credentials. static Future get webdav { - final secret = AccountData().getNextcloudSecret(); + // Keyed by user too: two accounts may share a password (no app password + // minted), and the client would keep the previous login name. + final secret = + '${AccountData().getUsername()}:${AccountData().getNextcloudSecret()}'; if (_webdav == null || _webdavSecret != secret) { _webdavSecret = secret; _webdav = establishWebdavConnection(); diff --git a/lib/api/marianumconnect/auth/auth_interceptor.dart b/lib/api/marianumconnect/auth/auth_interceptor.dart index b78ac97..7dc4e2a 100644 --- a/lib/api/marianumconnect/auth/auth_interceptor.dart +++ b/lib/api/marianumconnect/auth/auth_interceptor.dart @@ -88,17 +88,29 @@ class MarianumConnectAuthInterceptor extends Interceptor { Future _performReLogin() async { if (!AccountData().isPopulated()) return false; + final username = AccountData().getUsername(); + // A background engine (widget task) keeps the account it loaded. When + // the app signed that account out, its revoked token answers 401 — a + // re-login would mint a fresh token for it into the shared keystore. + if (await AccountData().readStoredUsername() != username) return false; try { await _loginClient.run( - username: AccountData().getUsername(), + username: username, password: AccountData().getPassword(), tokenName: await DeviceTokenName.resolve(), ); - return true; } catch (_) { - await _tokenStorage.clear(); + if (await AccountData().readStoredUsername() == username) { + await _tokenStorage.clear(); + } return false; } + final stored = await AccountData().readStoredUsername(); + if (stored == username) return true; + // Signed out during the login: drop the orphaned token, unless another + // account already stored its own. + if (stored == null) await _tokenStorage.clear(); + return false; } Future> _retryWithFreshToken( diff --git a/lib/api/marianumconnect/auth/session_validator.dart b/lib/api/marianumconnect/auth/session_validator.dart index 4280b4b..355b0bb 100644 --- a/lib/api/marianumconnect/auth/session_validator.dart +++ b/lib/api/marianumconnect/auth/session_validator.dart @@ -19,10 +19,14 @@ class SessionValidator { if (AccountData().isDemo) return; final username = AccountData().getUsername(); final password = AccountData().getPassword(); + final epoch = AccountData().sessionEpoch; try { await AuthVerify().run(username: username, password: password); } on AuthException catch (e) { if (e.statusCode != 401) return; + // The probed account already signed out; the 401 must not sign out + // whoever logged in meanwhile. + if (!AccountData().isCurrentSession(epoch)) return; log('MC: stored credentials rejected — forcing re-login'); await AuthLogout().run(); await const MarianumConnectTokenStorage().clear(); diff --git a/lib/api/marianumconnect/queries/timetable_custom_events/custom_events_migration.dart b/lib/api/marianumconnect/queries/timetable_custom_events/custom_events_migration.dart index ab63377..613e598 100644 --- a/lib/api/marianumconnect/queries/timetable_custom_events/custom_events_migration.dart +++ b/lib/api/marianumconnect/queries/timetable_custom_events/custom_events_migration.dart @@ -31,18 +31,23 @@ class CustomEventsMigration { if (DemoMode.active) return; if (await _isDone()) return; + final epoch = AccountData().sessionEpoch; try { final response = await GetCustomTimetableEvent( GetCustomTimetableEventParams(AccountData().getUserSecret()), ).run(); for (final event in response.events) { + // The POST authenticates with whoever is signed in now; after a + // sign-out the previous account's events would land in the next one. + if (!AccountData().isCurrentSession(epoch)) return; await TimetableCustomEventsAdd().run(event); await RemoveCustomTimetableEvent( RemoveCustomTimetableEventParams(event.id), ).run(); } + if (!AccountData().isCurrentSession(epoch)) return; await _markDone(); log('Custom events migration: moved ${response.events.length} event(s) to Marianum-Connect.'); } catch (e) { diff --git a/lib/api/request_cache.dart b/lib/api/request_cache.dart index 86d705b..bdf86ea 100644 --- a/lib/api/request_cache.dart +++ b/lib/api/request_cache.dart @@ -3,8 +3,10 @@ import 'dart:convert'; import 'package:localstore/localstore.dart'; +import '../model/account_data.dart'; import 'api_response.dart'; import 'errors/parse_exception.dart'; +import 'errors/stale_session_exception.dart'; abstract class RequestCache { static const int cacheNothing = 0; @@ -48,6 +50,7 @@ abstract class RequestCache { static void ignore(Exception e) {} Future start(String document) async { + final epoch = AccountData().sessionEpoch; try { final tableData = await Localstore.instance .collection(collection) @@ -67,6 +70,12 @@ abstract class RequestCache { try { final newValue = await onLoad(); + // The collection is shared, so a late response of a signed-out + // account would otherwise be cached for the next one. + if (!AccountData().isCurrentSession(epoch)) { + onError(const StaleSessionException()); + return; + } onUpdate?.call(newValue); onNetworkData?.call(newValue); unawaited( @@ -141,8 +150,10 @@ Future resolveFromCache( onError?.call(e); }); await cache.ready; - if (latest != null) return latest as T; final err = capturedError; + // `latest` may still hold the cache hit read before the sign-out. + if (err is StaleSessionException) throw err; + if (latest != null) return latest as T; if (err != null) throw err; throw ParseException( technicalDetails: operationName != null diff --git a/lib/app.dart b/lib/app.dart index c78f748..c346f69 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -145,13 +145,14 @@ class _AppState extends State with WidgetsBindingObserver { void _handlePendingShare() { if (!mounted) return; final share = ShareIntentListener.pending.value; - if (share == null) return; + if (share == null || ShareIntentListener.instance.isShown(share)) return; // A second share would otherwise leave the previous share-flow page // on top with stale (already-cleared) file paths. final navigator = Navigator.of(context); if (navigator.canPop()) { navigator.popUntil((route) => route.isFirst || route is PopupRoute); } + ShareIntentListener.instance.markShown(share); AppRoutes.openShareTarget(context, share); } @@ -200,7 +201,6 @@ class _AppState extends State with WidgetsBindingObserver { ); } unawaited(_handlePendingWidgetNavigation()); - ShareIntentListener.instance.attach(); ShareIntentListener.pending.addListener(_handlePendingShare); _handlePendingShare(); _syncChatListPolling(); @@ -261,7 +261,6 @@ class _AppState extends State with WidgetsBindingObserver { PushTapRouter.pendingChatToken.removeListener(_onPushTapPending); PushTapRouter.pendingNewsletterId.removeListener(_onNewsletterTapPending); ShareIntentListener.pending.removeListener(_handlePendingShare); - ShareIntentListener.instance.detach(); Main.bottomNavigator.removeListener(_onTabControllerChanged); WidgetsBinding.instance.removeObserver(this); super.dispose(); diff --git a/lib/main.dart b/lib/main.dart index e06a754..2eafc98 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -15,7 +15,6 @@ import 'package:jiffy/jiffy.dart'; import 'package:loader_overlay/loader_overlay.dart'; import 'package:path_provider/path_provider.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; -import 'package:shared_preferences/shared_preferences.dart'; import 'api/marianumcloud/webdav/queries/list_files/list_files_cache.dart'; import 'api/marianumconnect/auth/session_validator.dart'; @@ -27,6 +26,7 @@ import 'app.dart'; import 'background/widget_background_task.dart'; import 'firebase_options.dart'; import 'model/account_data.dart'; +import 'model/session_wipe.dart'; import 'notification/notification_service.dart'; import 'push/push_message_handler.dart'; import 'push/push_registration.dart'; @@ -54,7 +54,6 @@ import 'view/login/login.dart'; import 'view/login/post_login_splash.dart'; import 'widget/avatar_disk_cache.dart'; import 'widget/breaker/breaker.dart'; -import 'widget/debug/cache_view.dart'; import 'widget/downloads/download_tray.dart'; import 'widget/emergency/emergency_notice_gate.dart'; import 'widget_data/widget_sync.dart'; @@ -117,6 +116,7 @@ void _installErrorHandlers() { Future main() async { log('MarianumMobile started'); WidgetsFlutterBinding.ensureInitialized(); + AccountData().markUiEngine(); // Before any initialisation so startup failures reach the backend too. _installErrorHandlers(); @@ -272,11 +272,13 @@ class Main extends StatefulWidget { class _MainState extends State
{ bool _showPostLoginSplash = false; bool _appMounted = true; + late AccountStatus _lastStatus; @override void initState() { super.initState(); Jiffy.setLocale('de'); + _lastStatus = context.read().state.status; AccountData().waitForPopulation().then((value) { if (!mounted) return; @@ -409,6 +411,8 @@ class _MainState extends State
{ listenWhen: (previous, current) => previous.status != current.status, listener: (context, accountState) { + final wasLoggedIn = _lastStatus == AccountStatus.loggedIn; + _lastStatus = accountState.status; // Fresh login (loggedOut -> loggedIn): pull capability flags // for the newly authenticated user, then register push right // away instead of deferring it to the next app start. @@ -434,9 +438,10 @@ class _MainState extends State
{ } if (accountState.status != AccountStatus.loggedOut) return; // A pending share would otherwise survive logout and be - // re-applied after re-login with file paths the OS may - // already have evicted from the cache. - ShareIntentListener.instance.clear(); + // re-applied to the next account. A cold-start share that + // is merely waiting for the stored session to resolve as + // signed out stays, to be sent after login. + if (wasLoggedIn) SessionWipe.clearImmediate(); // Routes pushed via AppRoutes (e.g. Settings) live on the // root navigator and survive the home swap below, so they // would still cover the Login screen after logout. Pop @@ -448,7 +453,6 @@ class _MainState extends State
{ // Capture bloc references before the post-frame callback // — by the time it runs the dialog/Settings context is // gone but this listener context is still valid. - final settingsCubit = context.read(); final timetableBloc = context.read(); final chatListBloc = context.read(); final chatBloc = context.read(); @@ -462,8 +466,7 @@ class _MainState extends State
{ // still in front caused a black-frame race. WidgetsBinding.instance.addPostFrameCallback((_) { unawaited( - _wipeUserState( - settingsCubit: settingsCubit, + SessionWipe.run( timetableBloc: timetableBloc, chatListBloc: chatListBloc, chatBloc: chatBloc, @@ -507,45 +510,3 @@ class _MainState extends State
{ ), ); } - -Future _wipeUserState({ - required SettingsCubit settingsCubit, - required TimetableBloc timetableBloc, - required ChatListBloc chatListBloc, - required ChatBloc chatBloc, - required BreakerBloc breakerBloc, - required CapabilitiesCubit capabilitiesCubit, - required NextcloudCapabilitiesCubit nextcloudCapabilitiesCubit, -}) async { - try { - // Reset user-data blocs whose tree is no longer mounted after the - // home swap. We do NOT touch SettingsCubit here — its outer BlocBuilder - // wraps MaterialApp, so emit'ing a fresh state would tear down the - // freshly-mounted Login tree and leave the user with a blank screen - // (the MaterialApp.builder backdrop) until the next interaction. - await Future.wait([ - timetableBloc.reset(), - chatListBloc.reset(), - chatBloc.reset(), - breakerBloc.reset(), - capabilitiesCubit.reset(), - nextcloudCapabilitiesCubit.reset(), - ]); - final prefs = await SharedPreferences.getInstance(); - await prefs.clear(); - await HydratedBloc.storage.clear(); - await const CacheView().clear(); - // The chat background image lives outside HydratedStorage, so clear it too - // (best-effort) to avoid orphaning the previous user's wallpaper. - final backgroundImage = File(AppPaths.chatBackgroundImage); - if (backgroundImage.existsSync()) backgroundImage.deleteSync(); - // 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 WidgetBackgroundTask.cancelAll(); - await WidgetSync.clear(); - await WidgetSync.triggerUpdate(); - } catch (e, s) { - log('User state wipe failed: $e', stackTrace: s); - } -} diff --git a/lib/model/account_data.dart b/lib/model/account_data.dart index 4f96459..518d5fb 100644 --- a/lib/model/account_data.dart +++ b/lib/model/account_data.dart @@ -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 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 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 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 waitForPopulation() async { await _populated.future; return isPopulated(); diff --git a/lib/model/session_wipe.dart b/lib/model/session_wipe.dart new file mode 100644 index 0000000..b54eff8 --- /dev/null +++ b/lib/model/session_wipe.dart @@ -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? _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 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 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 _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 _stepAsync( + String name, + Future Function() step, + ) async { + try { + await step(); + } catch (e, s) { + log('Session wipe step "$name" failed: $e', stackTrace: s); + } + } +} diff --git a/lib/push/push_actions.dart b/lib/push/push_actions.dart index 3548143..a2d1f42 100644 --- a/lib/push/push_actions.dart +++ b/lib/push/push_actions.dart @@ -60,6 +60,10 @@ class PushActions { // auth header, the Talk POST never happens and the RemoteInput spinner // runs forever. DartPluginRegistrant.ensureInitialized(); + // The action engine lives as long as the process: without a reload a + // reply would be sent with the account that was signed in when the + // engine first started. + await AccountData().reloadFromStorage(); _plog( 'action=${response.actionId} payload=${response.payload} ' diff --git a/lib/push/push_message_handler.dart b/lib/push/push_message_handler.dart index 1d64116..277c2c0 100644 --- a/lib/push/push_message_handler.dart +++ b/lib/push/push_message_handler.dart @@ -4,6 +4,7 @@ import 'package:crypton/crypton.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import '../background/widget_background_task.dart'; +import '../model/account_data.dart'; import '../notification/notification_service.dart'; import 'chat_thread_store.dart'; import 'nid_store.dart'; @@ -54,6 +55,8 @@ PushKind classifyPush(Map data) { /// "must be annotated"), so a plain function is the reliable form. @pragma('vm:entry-point') Future pushOnBackgroundMessage(RemoteMessage message) async { + // This engine outlives sign-outs and logins in the app. + await AccountData().reloadFromStorage(); await NotificationService().initializeNotifications(); await PushRenderer.ensureChannels(); await PushMessageHandler().handle(message); diff --git a/lib/push/push_registration.dart b/lib/push/push_registration.dart index 1f0d1b7..52dc540 100644 --- a/lib/push/push_registration.dart +++ b/lib/push/push_registration.dart @@ -107,6 +107,7 @@ class PushRegistration { /// fire-and-forget (and simply ignore the result). Future register() async { if (DemoMode.active) return false; + final epoch = AccountData().sessionEpoch; final String? fcmToken; try { fcmToken = await FirebaseMessaging.instance.getToken(); @@ -159,6 +160,7 @@ class PushRegistration { fcmToken: fcmToken, pems: pems, appVersion: appVersion, + epoch: epoch, ); allOk = allOk && ok; } @@ -170,6 +172,7 @@ class PushRegistration { required String fcmToken, required PushKeypairPems pems, required String? appVersion, + required int epoch, }) async { try { final proxyServer = currentProxyServer; @@ -185,6 +188,11 @@ class PushRegistration { userAgent: isTalk ? _talkUserAgent : null, ); + // Signed out while registering: persisting or announcing this + // registration would keep delivering the previous account's pushes, + // and logoutCleanup already ran so nothing would unregister it. + if (!AccountData().isCurrentSession(epoch)) return false; + await _store.save( type: type, deviceIdentifier: registration.deviceIdentifier, diff --git a/lib/share_intent/pending_share.dart b/lib/share_intent/pending_share.dart index 9f8590e..6c875e2 100644 --- a/lib/share_intent/pending_share.dart +++ b/lib/share_intent/pending_share.dart @@ -5,11 +5,17 @@ class PendingShare { final String? text; final DateTime receivedAt; + /// Paths as delivered by the platform, before the listener moved them into + /// a per-share folder. Duplicate detection compares these, since the + /// relocated [filePaths] differ for every delivery. + final List sourcePaths; + const PendingShare({ required this.filePaths, required this.text, required this.receivedAt, - }); + List? sourcePaths, + }) : sourcePaths = sourcePaths ?? filePaths; bool get hasFiles => filePaths.isNotEmpty; bool get hasText => text != null && text!.isNotEmpty; @@ -20,5 +26,5 @@ class PendingShare { /// the same share can arrive twice on the media stream — receivedAt is /// deliberately ignored here so such duplicates compare equal. bool contentEquals(PendingShare other) => - text == other.text && listEquals(filePaths, other.filePaths); + text == other.text && listEquals(sourcePaths, other.sourcePaths); } diff --git a/lib/share_intent/share_intent_listener.dart b/lib/share_intent/share_intent_listener.dart index 27b429d..f1f355b 100644 --- a/lib/share_intent/share_intent_listener.dart +++ b/lib/share_intent/share_intent_listener.dart @@ -2,6 +2,8 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:receive_sharing_intent/receive_sharing_intent.dart'; import 'pending_share.dart'; @@ -14,56 +16,63 @@ class ShareIntentListener { static final ValueNotifier pending = ValueNotifier(null); - StreamSubscription>? _streamSub; - bool _initialized = false; + static const _androidChannel = MethodChannel('eu.mhsl.marianum.share'); - /// Reads the cold-start payload exactly once. Call from `main()` before - /// `runApp` so the share is queued before the UI mounts. + /// Per-share folders live below this directory name — on Android in the + /// cache dir (created here), on iOS in the App Group container (created by + /// the Share Extension). + static const _shareDirName = 'share_intent'; + + bool _initialized = false; + String? _cacheDir; + + int _flowDepth = 0; + PendingShare? _queued; + PendingShare? _shown; + + /// Subscribes to warm shares and reads the cold-start payload. Call from + /// `main()` before `runApp`. The subscription stays for the process + /// lifetime: the plugin drops shares that arrive while nobody listens + /// (login screen, app remount), and [pending] buffers until the UI is ready. Future initialize() async { if (_initialized) return; _initialized = true; try { - final initial = await ReceiveSharingIntent.instance.getInitialMedia(); - final share = _toPendingShare(initial); - if (share != null) _publish(share); + if (Platform.isAndroid) { + _cacheDir = (await getTemporaryDirectory()).path; + _sweepStaleAndroidShares(); + } + ReceiveSharingIntent.instance.getMediaStream().listen( + _onItems, + onError: (Object e) => debugPrint('ShareIntentListener stream: $e'), + ); + _onItems(await ReceiveSharingIntent.instance.getInitialMedia()); await ReceiveSharingIntent.instance.reset(); + if (Platform.isAndroid) { + await _androidChannel.invokeMethod('listenerReady'); + } } catch (e) { debugPrint('ShareIntentListener.initialize failed: $e'); } } - /// Subscribes to warm-share stream events. Safe to call multiple times. - void attach() { - _streamSub ??= ReceiveSharingIntent.instance.getMediaStream().listen( - (items) { - final share = _toPendingShare(items); - if (share != null) _publish(share); - }, - onError: (Object e) => - debugPrint('ShareIntentListener stream error: $e'), - ); + /// Whether the share flow for [share] is already on screen. Re-running the + /// navigation for it would pop its own page and thereby clear it. + bool isShown(PendingShare share) => identical(share, _shown); + + void markShown(PendingShare share) => _shown = share; + + /// Marks a share flow step that must not be interrupted (upload, share API + /// call). Shares arriving meanwhile are queued instead of replacing the + /// current one, whose files are still in use. Pair with [endFlow]. + void beginFlow() => _flowDepth++; + + void endFlow() { + if (_flowDepth > 0) _flowDepth--; + if (_flowDepth == 0) _drainQueue(); } - /// The iOS Share Extension fires two `open(url)` requests per share, so the - /// same payload can arrive twice in quick succession. Publishing the - /// duplicate would re-trigger the share-flow navigation, pop the already - /// open ShareTargetPage and thereby delete the temp files of the share that - /// is still in flight — swallow it instead. - void _publish(PendingShare share) { - final current = pending.value; - if (current != null && current.contentEquals(share)) return; - pending.value = share; - } - - /// Cancels the warm-share subscription. The singleton survives, so a - /// subsequent [attach] re-subscribes. - void detach() { - _streamSub?.cancel(); - _streamSub = null; - } - - /// Discards the current share and removes any temp files the plugin copied - /// into the app cache. Idempotent. + /// Discards the current share and deletes its temp files. Idempotent. /// /// Pass [ifCurrent] from UI that owns a specific share (e.g. the /// ShareTargetPage pop handler): the call then only acts while that share @@ -73,18 +82,58 @@ class ShareIntentListener { void clear({PendingShare? ifCurrent}) { final current = pending.value; if (ifCurrent != null && !identical(current, ifCurrent)) return; + _shown = null; pending.value = null; - if (current != null) { - for (final path in current.filePaths) { - try { - final f = File(path); - if (f.existsSync()) f.deleteSync(); - } catch (_) { - // best-effort cleanup; OS will reclaim cache eventually - } + if (current != null) _deleteFiles(current); + unawaited(ReceiveSharingIntent.instance.reset()); + if (_flowDepth == 0) _drainQueue(); + } + + /// Drops every share, including a queued one — for sign-out. + void clearAll() { + final queued = _queued; + _queued = null; + _flowDepth = 0; + if (queued != null) _deleteFiles(queued); + clear(); + } + + void _onItems(List items) { + final share = _toPendingShare(items); + if (share == null) return; + // The iOS Share Extension opens the app twice per share, so the same + // payload can arrive again while it is still pending. Publishing it would + // re-run the navigation and pop the share that is in flight. + for (final live in [pending.value, _queued]) { + if (live != null && live.contentEquals(share)) { + _deleteFiles(share, keep: live.filePaths); + return; } } - unawaited(ReceiveSharingIntent.instance.reset()); + final claimed = _claim(share); + if (_flowDepth > 0) { + final replaced = _queued; + _queued = claimed; + if (replaced != null) _deleteFiles(replaced); + return; + } + _replaceCurrent(claimed); + } + + void _drainQueue() { + final queued = _queued; + if (queued == null) return; + _queued = null; + _replaceCurrent(queued); + } + + // No flow is running, so nothing uses the previous share's files anymore; + // its pages are popped when the new share is routed. + void _replaceCurrent(PendingShare share) { + final previous = pending.value; + _shown = null; + pending.value = share; + if (previous != null) _deleteFiles(previous); } PendingShare? _toPendingShare(List items) { @@ -96,7 +145,9 @@ class ShareIntentListener { case SharedMediaType.image: case SharedMediaType.video: case SharedMediaType.file: - files.add(item.path); + // A replayed share can point at temp copies deleted after the + // original share completed. + if (File(item.path).existsSync()) files.add(item.path); case SharedMediaType.text: case SharedMediaType.url: texts.add(item.path); @@ -109,4 +160,77 @@ class ShareIntentListener { receivedAt: DateTime.now(), ); } + + /// The Android plugin copies shared content to `cacheDir/`, + /// so a second share with an equally named file would overwrite the first + /// one's copy. Move owned copies into a folder of their own. iOS shares + /// already arrive in a per-share folder. + PendingShare _claim(PendingShare share) { + final cacheDir = _cacheDir; + if (!Platform.isAndroid || cacheDir == null || !share.hasFiles) { + return share; + } + final dir = Directory( + '$cacheDir/$_shareDirName/${share.receivedAt.microsecondsSinceEpoch}', + ); + final claimed = []; + for (final (i, path) in share.filePaths.indexed) { + if (!_isOwned(path)) { + claimed.add(path); + continue; + } + try { + dir.createSync(recursive: true); + final name = path.split(Platform.pathSeparator).last; + var target = '${dir.path}/$name'; + if (File(target).existsSync()) target = '${dir.path}/$i-$name'; + claimed.add(File(path).renameSync(target).path); + } catch (_) { + claimed.add(path); + } + } + return PendingShare( + filePaths: claimed, + text: share.text, + receivedAt: share.receivedAt, + sourcePaths: share.sourcePaths, + ); + } + + /// Only temp copies are deleted: on Android the plugin hands out the real + /// path for some providers (e.g. a file in Downloads), which must survive. + bool _isOwned(String path) { + if (Platform.isIOS) return true; + final cacheDir = _cacheDir; + return cacheDir != null && path.startsWith('$cacheDir/'); + } + + void _deleteFiles(PendingShare share, {List keep = const []}) { + for (final path in share.filePaths) { + if (keep.contains(path) || !_isOwned(path)) continue; + try { + final file = File(path); + if (file.existsSync()) file.deleteSync(); + final dir = file.parent; + if (dir.parent.path.endsWith('/$_shareDirName') && + dir.listSync().isEmpty) { + dir.deleteSync(); + } + } catch (_) { + // best-effort cleanup; OS will reclaim cache eventually + } + } + } + + // Shares whose flow never finished (process killed) leave their folder + // behind; the pending one of this process is not claimed yet. + void _sweepStaleAndroidShares() { + try { + final root = Directory('$_cacheDir/$_shareDirName'); + if (!root.existsSync()) return; + root.deleteSync(recursive: true); + } catch (_) { + // best-effort + } + } } diff --git a/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart index 8fe3ffd..0234055 100644 --- a/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart +++ b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart @@ -1,8 +1,11 @@ +import 'dart:async'; import 'dart:developer'; import 'package:hydrated_bloc/hydrated_bloc.dart'; import '../../../../../api/errors/error_mapper.dart'; +import '../../../../../api/errors/stale_session_exception.dart'; +import '../../../../../model/account_data.dart'; import '../../loadable_state/loadable_state.dart'; import '../../loadable_state/loading_error.dart'; import '../../repository/repository.dart'; @@ -114,6 +117,23 @@ abstract class LoadableHydratedBloc< add(Reset()); } + static const _sessionKey = #loadableSessionEpoch; + + /// Runs [body] tagged with the current session: events it adds, also from + /// its async continuations, are dropped once the account signed out, so a + /// late response of the previous account cannot refill the reset bloc. + R runInSession(R Function() body) => runZoned( + body, + zoneValues: {_sessionKey: AccountData().sessionEpoch}, + ); + + @override + void add(LoadableHydratedBlocEvent event) { + final epoch = Zone.current[_sessionKey]; + if (epoch is int && !AccountData().isCurrentSession(epoch)) return; + super.add(event); + } + TState? get innerState => state.data; TRepository get repo => _repository; @@ -126,29 +146,35 @@ abstract class LoadableHydratedBloc< /// Maps [e] through the shared error mapper and emits it as an [Error] event. /// Does not guard [isClosed] — callers decide whether a late error still /// applies. - void addLoadingError(Object e) => add( - Error( - LoadingError( - message: errorToUserMessage(e), - technicalDetails: errorToTechnicalDetails(e), - allowRetry: errorAllowsRetry(e), + void addLoadingError(Object e) { + // Belongs to a signed-out account; the current one loads on its own. + if (e is StaleSessionException) return; + add( + Error( + LoadingError( + message: errorToUserMessage(e), + technicalDetails: errorToTechnicalDetails(e), + allowRetry: errorAllowsRetry(e), + ), ), - ), - ); + ); + } void fetch() { log('Fetching data for ${TState.toString()}'); - gatherData() - .catchError((Object e) { - log('Error while fetching ${TState.toString()}: ${e.toString()}'); - // The bloc may have been closed before this async error landed; - // adding to a closed bloc throws, so swallow that case. - if (isClosed) return; - addLoadingError(e); - }) - .then((value) { - log('Fetch for ${TState.toString()} completed!'); - }); + runInSession( + () => gatherData() + .catchError((Object e) { + log('Error while fetching ${TState.toString()}: ${e.toString()}'); + // The bloc may have been closed before this async error landed; + // adding to a closed bloc throws, so swallow that case. + if (isClosed) return; + addLoadingError(e); + }) + .then((value) { + log('Fetch for ${TState.toString()} completed!'); + }), + ); } @override diff --git a/lib/state/app/modules/capabilities/bloc/capabilities_cubit.dart b/lib/state/app/modules/capabilities/bloc/capabilities_cubit.dart index 8290af6..1bc141b 100644 --- a/lib/state/app/modules/capabilities/bloc/capabilities_cubit.dart +++ b/lib/state/app/modules/capabilities/bloc/capabilities_cubit.dart @@ -5,6 +5,7 @@ import 'package:hydrated_bloc/hydrated_bloc.dart'; import '../../../../../api/demo/data/demo_capabilities.dart'; import '../../../../../api/demo/demo_mode.dart'; import '../../../../../api/marianumconnect/queries/get_capabilities/get_capabilities.dart'; +import '../../../../../model/account_data.dart'; import 'capabilities_state.dart'; /// Holds the current user's mobile capability flags. Hydrated so the last @@ -34,8 +35,12 @@ class CapabilitiesCubit extends HydratedCubit { emit(DemoCapabilities.state()); return; } + final epoch = AccountData().sessionEpoch; try { final response = await GetCapabilities().run(); + // A slow answer for the previous account must not decide the modules + // of the next one. + if (!AccountData().isCurrentSession(epoch)) return; emit( CapabilitiesState( viewForeignTimetables: response.viewForeignTimetables, diff --git a/lib/state/app/modules/chat/bloc/chat_bloc.dart b/lib/state/app/modules/chat/bloc/chat_bloc.dart index f8384b2..75dfd52 100644 --- a/lib/state/app/modules/chat/bloc/chat_bloc.dart +++ b/lib/state/app/modules/chat/bloc/chat_bloc.dart @@ -123,6 +123,15 @@ class ChatBloc /// No-op when the bloc has already moved on to a different token: when /// popping a stacked chat (B over A), A's didPopNext runs setToken(A) /// before B's dispose fires. + /// The chat view may still be popping when the sign-out resets this bloc, + /// so leaveChat would find no token and the long-poll would keep running. + @override + Future reset() { + _chatViewActive = false; + _stopLongPoll(); + return super.reset(); + } + void leaveChat(String fromToken) { if ((innerState?.currentToken ?? '') != fromToken) return; _chatViewActive = false; diff --git a/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart b/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart index a789254..caaa3e8 100644 --- a/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart +++ b/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart @@ -30,6 +30,13 @@ class ChatListBloc return super.close(); } + // The timer outlives the app shell; the next shell re-arms it after login. + @override + Future reset() { + setAutoRefreshInterval(null); + return super.reset(); + } + /// Silent refresh — explicit pull-to-refresh and tab-activation are non-silent. void setAutoRefreshInterval(Duration? interval) { if (interval == _autoRefreshInterval) return; diff --git a/lib/state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart b/lib/state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart index 6933eab..c2e34be 100644 --- a/lib/state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart +++ b/lib/state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart @@ -5,6 +5,7 @@ import 'package:hydrated_bloc/hydrated_bloc.dart'; import '../../../../../api/demo/data/demo_capabilities.dart'; import '../../../../../api/demo/demo_mode.dart'; import '../../../../../api/marianumcloud/capabilities/get_nextcloud_capabilities.dart'; +import '../../../../../model/account_data.dart'; import 'nextcloud_capabilities_state.dart'; /// Holds the current user's Nextcloud `files_sharing` capabilities. Hydrated so @@ -63,8 +64,12 @@ class NextcloudCapabilitiesCubit emit(DemoNextcloudCapabilities.state()); return; } + final epoch = AccountData().sessionEpoch; try { final caps = await GetNextcloudCapabilities().run(); + // A slow answer for the previous account must not decide the modules + // of the next one. + if (!AccountData().isCurrentSession(epoch)) return; emit( NextcloudCapabilitiesState( apiEnabled: caps.apiEnabled, diff --git a/lib/state/app/modules/timetable/bloc/timetable_bloc.dart b/lib/state/app/modules/timetable/bloc/timetable_bloc.dart index 8519361..e797e37 100644 --- a/lib/state/app/modules/timetable/bloc/timetable_bloc.dart +++ b/lib/state/app/modules/timetable/bloc/timetable_bloc.dart @@ -91,8 +91,10 @@ class TimetableBloc final current = innerState ?? fromNothing(); if (current.startDate == startDate && current.endDate == endDate) return; add(Emit((s) => s.copyWith(startDate: startDate, endDate: endDate))); - _loadCurrentWeek(startDate, endDate); - _prefetchAdjacentWeeks(startDate, endDate); + runInSession(() { + _loadCurrentWeek(startDate, endDate); + _prefetchAdjacentWeeks(startDate, endDate); + }); } void resetWeek() { diff --git a/lib/view/login/login_controller.dart b/lib/view/login/login_controller.dart index cb25b36..ee6c419 100644 --- a/lib/view/login/login_controller.dart +++ b/lib/view/login/login_controller.dart @@ -11,6 +11,7 @@ import '../../api/marianumconnect/auth/token_storage.dart'; import '../../api/marianumconnect/queries/auth_login/auth_login.dart'; import '../../api/marianumconnect/queries/auth_logout/auth_logout.dart'; import '../../model/account_data.dart'; +import '../../model/session_wipe.dart'; import '../../widget_data/widget_sync.dart'; /// Outcome of a login attempt. @@ -46,6 +47,10 @@ class LoginController extends ChangeNotifier { _errorDetails = null; notifyListeners(); + // The previous account's wipe runs deferred; racing it would let it + // clear what this login stores (widget job, caches). + await SessionWipe.done; + final user = username.trim().toLowerCase(); // Demo login: the prefix enters local demo mode, password ignored, no diff --git a/lib/view/pages/files/files_upload_dialog.dart b/lib/view/pages/files/files_upload_dialog.dart index 95502b7..bec671f 100644 --- a/lib/view/pages/files/files_upload_dialog.dart +++ b/lib/view/pages/files/files_upload_dialog.dart @@ -53,15 +53,6 @@ class _FilesUploadDialogState extends State { }).toList(); } - void showHttpErrorCode(int httpErrorCode) { - InfoDialog.show( - context, - 'Error code: $httpErrorCode', - title: 'Ein Fehler ist aufgetreten', - copyable: true, - ); - } - void _resetProgress() { _isUploading = false; _overallProgressValue = 0.0; @@ -221,14 +212,17 @@ class _FilesUploadDialogState extends State { return; } + if (!mounted) return; + // Stay on the dialog: popping here reported the partial result as a + // success to onUploadFinished and closed the error dialog right away. if (uploadTask.statusCode < 200 || uploadTask.statusCode > 299) { - setState(_resetProgress); - if (!mounted) return; - Navigator.of(context).pop(); - showHttpErrorCode(uploadTask.statusCode); - } else { - uploadetFilePaths.add(fullRemotePath); + _showUploadError( + 'Upload fehlgeschlagen für "$fileName" ' + '(Fehlercode ${uploadTask.statusCode}).', + ); + return; } + uploadetFilePaths.add(fullRemotePath); } setState(_resetProgress); diff --git a/lib/view/pages/share_intent/share_chat_picker.dart b/lib/view/pages/share_intent/share_chat_picker.dart index 905ebd9..10882a0 100644 --- a/lib/view/pages/share_intent/share_chat_picker.dart +++ b/lib/view/pages/share_intent/share_chat_picker.dart @@ -135,19 +135,24 @@ Future _externalShareFlow( PendingShare share, ) async { if (share.hasFiles) { - await ensureTalkShareFolder(); - if (!context.mounted) return; - await pushScreen( - context, - withNavBar: false, - screen: FilesUploadDialog( - filePaths: share.filePaths, - remotePath: talkShareFolder, - uniqueNames: true, - onUploadFinished: (uploaded) => - _afterExternalFilesUploaded(context, room, uploaded, share), - ), - ); + ShareIntentListener.instance.beginFlow(); + try { + await ensureTalkShareFolder(); + if (!context.mounted) return; + await pushScreen( + context, + withNavBar: false, + screen: FilesUploadDialog( + filePaths: share.filePaths, + remotePath: talkShareFolder, + uniqueNames: true, + onUploadFinished: (uploaded) => + _afterExternalFilesUploaded(context, room, uploaded, share), + ), + ); + } finally { + ShareIntentListener.instance.endFlow(); + } return; } if (share.hasText) { @@ -155,17 +160,28 @@ Future _externalShareFlow( } } +// Called synchronously when the upload dialog pops, so the flow is entered +// before the upload step above releases it. Future _afterExternalFilesUploaded( BuildContext context, GetRoomResponseObject room, List uploadedRemotePaths, PendingShare share, -) => _runShareFlow( - context, - action: () => - shareFilesToChat(token: room.token, remoteFilePaths: uploadedRemotePaths), - onSuccess: () => _setExternalDraftAndOpenChat(context, room, share), -); +) async { + ShareIntentListener.instance.beginFlow(); + try { + await _runShareFlow( + context, + action: () => shareFilesToChat( + token: room.token, + remoteFilePaths: uploadedRemotePaths, + ), + onSuccess: () => _setExternalDraftAndOpenChat(context, room, share), + ); + } finally { + ShareIntentListener.instance.endFlow(); + } +} /// Shared share-flow scaffolding: shows the blocking spinner, runs [action], /// maps failures to an error dialog (popping the spinner first), and invokes diff --git a/lib/view/pages/share_intent/share_folder_picker.dart b/lib/view/pages/share_intent/share_folder_picker.dart index 1c94522..e6f5fc9 100644 --- a/lib/view/pages/share_intent/share_folder_picker.dart +++ b/lib/view/pages/share_intent/share_folder_picker.dart @@ -206,16 +206,21 @@ Future _externalUploadFlow( List targetPath, PendingShare share, ) async { - await pushScreen( - context, - withNavBar: false, - screen: FilesUploadDialog( - filePaths: share.filePaths, - remotePath: targetPath.join('/'), - onUploadFinished: (_) => - _afterExternalUploaded(context, targetPath, share), - ), - ); + ShareIntentListener.instance.beginFlow(); + try { + await pushScreen( + context, + withNavBar: false, + screen: FilesUploadDialog( + filePaths: share.filePaths, + remotePath: targetPath.join('/'), + onUploadFinished: (_) => + _afterExternalUploaded(context, targetPath, share), + ), + ); + } finally { + ShareIntentListener.instance.endFlow(); + } } void _afterExternalUploaded( diff --git a/lib/widget_data/widget_publisher.dart b/lib/widget_data/widget_publisher.dart index 9cac9a5..e5233f6 100644 --- a/lib/widget_data/widget_publisher.dart +++ b/lib/widget_data/widget_publisher.dart @@ -3,6 +3,7 @@ import 'dart:developer'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import '../model/account_data.dart'; import '../state/app/modules/timetable/bloc/timetable_state.dart'; import '../storage/settings.dart'; import 'widget_data_mapper.dart'; @@ -24,6 +25,7 @@ class WidgetPublisher { Settings? settings, bool isTeacher = false, }) async { + final epoch = AccountData().sessionEpoch; try { final connectDouble = settings?.timetableSettings.connectDoubleLessons ?? true; @@ -58,6 +60,9 @@ class WidgetPublisher { connectDoubleLessons: connectDouble, showClassInsteadOfTeacher: isTeacher, ); + // A publish still running at sign-out would put the previous account's + // plan back onto the just cleared widget. + if (!AccountData().isCurrentSession(epoch)) return; await WidgetSync.writeDayData(dayData); await WidgetSync.writeWeekData(weekData); await WidgetSync.setLoggedIn(true); diff --git a/test/share_intent/pending_share_test.dart b/test/share_intent/pending_share_test.dart index 0e88987..a5e2987 100644 --- a/test/share_intent/pending_share_test.dart +++ b/test/share_intent/pending_share_test.dart @@ -62,5 +62,18 @@ void main() { test('empty shares compare equal', () { expect(_share().contentEquals(_share()), isTrue); }); + + test('relocated share still equals its raw delivery', () { + final raw = _share(filePaths: ['/cache/a.jpg']); + final claimed = PendingShare( + filePaths: ['/cache/share_intent/1/a.jpg'], + text: null, + receivedAt: DateTime(2026, 1, 1), + sourcePaths: raw.filePaths, + ); + + expect(claimed.contentEquals(raw), isTrue); + expect(raw.contentEquals(claimed), isTrue); + }); }); }