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
+1 -1
View File
@@ -9,7 +9,7 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:launchMode="singleTask"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
@@ -1,15 +1,38 @@
package eu.mhsl.marianum.mobile.client
import android.content.Intent
import android.os.Bundle
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() {
private val widgetChannel = "eu.mhsl.marianum.widget"
private val shareChannel = "eu.mhsl.marianum.share"
/// Last seen widget tap target. Cleared by Dart via `consumePendingNavigation`
/// so the same intent isn't replayed on every resume.
private var pendingTimetableTap: Boolean = false
private val shareActions = setOf(Intent.ACTION_SEND, Intent.ACTION_SEND_MULTIPLE)
/// receive_sharing_intent only emits warm shares to a live Dart listener and
/// does not buffer them. After a relaunch into an existing task the share
/// arrives via onNewIntent before Dart subscribed, so it is held here until
/// Dart reports `listenerReady`.
private var shareListenerReady = false
private var bufferedShare: Intent? = null
override fun onCreate(savedInstanceState: Bundle?) {
// Android replays the original launch intent when the activity is
// recreated from recents or after process death. A share or widget tap
// that was already handled would otherwise run again — for shares with
// temp files that are long gone.
val replayed = savedInstanceState != null ||
(intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) != 0
if (replayed && (intent.action in shareActions || isWidgetTap(intent))) {
intent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
}
super.onCreate(savedInstanceState)
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
@@ -26,17 +49,35 @@ class MainActivity : FlutterActivity() {
else -> 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
}
}
+25 -2
View File
@@ -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<UIOpenURLContext>) {
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")
}
}
+46 -10
View File
@@ -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
}
@@ -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',
);
}
@@ -17,6 +17,10 @@ Future<void>? _shareFolderReady;
Future<void> ensureTalkShareFolder() =>
_shareFolderReady ??= _createTalkShareFolder();
/// The folder is per user; after a sign-out the next account has to check
/// again.
void resetTalkShareFolderCache() => _shareFolderReady = null;
Future<void> _createTalkShareFolder() async {
try {
final webdav = await WebdavApi.webdav;
+4 -1
View File
@@ -18,7 +18,10 @@ abstract class WebdavApi<T> {
/// changes (app password minted/renewed, account switch) so it never keeps
/// authenticating with stale credentials.
static Future<WebDavClient> 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();
@@ -88,17 +88,29 @@ class MarianumConnectAuthInterceptor extends Interceptor {
Future<bool> _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<Response<dynamic>> _retryWithFreshToken(
@@ -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();
@@ -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) {
+12 -1
View File
@@ -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<T extends ApiResponse?> {
static const int cacheNothing = 0;
@@ -48,6 +50,7 @@ abstract class RequestCache<T extends ApiResponse?> {
static void ignore(Exception e) {}
Future<void> start(String document) async {
final epoch = AccountData().sessionEpoch;
try {
final tableData = await Localstore.instance
.collection(collection)
@@ -67,6 +70,12 @@ abstract class RequestCache<T extends ApiResponse?> {
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<T> resolveFromCache<T extends ApiResponse?>(
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
+2 -3
View File
@@ -145,13 +145,14 @@ class _AppState extends State<App> 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<App> with WidgetsBindingObserver {
);
}
unawaited(_handlePendingWidgetNavigation());
ShareIntentListener.instance.attach();
ShareIntentListener.pending.addListener(_handlePendingShare);
_handlePendingShare();
_syncChatListPolling();
@@ -261,7 +261,6 @@ class _AppState extends State<App> 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();
+11 -50
View File
@@ -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<void> 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<Main> {
bool _showPostLoginSplash = false;
bool _appMounted = true;
late AccountStatus _lastStatus;
@override
void initState() {
super.initState();
Jiffy.setLocale('de');
_lastStatus = context.read<AccountBloc>().state.status;
AccountData().waitForPopulation().then((value) {
if (!mounted) return;
@@ -409,6 +411,8 @@ class _MainState extends State<Main> {
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<Main> {
}
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<Main> {
// 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<SettingsCubit>();
final timetableBloc = context.read<TimetableBloc>();
final chatListBloc = context.read<ChatListBloc>();
final chatBloc = context.read<ChatBloc>();
@@ -462,8 +466,7 @@ class _MainState extends State<Main> {
// 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<Main> {
),
);
}
Future<void> _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);
}
}
+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);
}
}
}
+4
View File
@@ -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} '
+3
View File
@@ -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<String, dynamic> data) {
/// "must be annotated"), so a plain function is the reliable form.
@pragma('vm:entry-point')
Future<void> 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);
+8
View File
@@ -107,6 +107,7 @@ class PushRegistration {
/// fire-and-forget (and simply ignore the result).
Future<bool> 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,
+8 -2
View File
@@ -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<String> sourcePaths;
const PendingShare({
required this.filePaths,
required this.text,
required this.receivedAt,
});
List<String>? 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);
}
+171 -47
View File
@@ -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<PendingShare?> pending = ValueNotifier(null);
StreamSubscription<List<SharedMediaFile>>? _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<void> 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<void>('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<SharedMediaFile> 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<SharedMediaFile> 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/<display name>`,
/// 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 = <String>[];
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<String> 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
}
}
}
@@ -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<TState>());
}
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>(R Function() body) => runZoned(
body,
zoneValues: {_sessionKey: AccountData().sessionEpoch},
);
@override
void add(LoadableHydratedBlocEvent<TState> 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
@@ -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<CapabilitiesState> {
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,
@@ -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<void> reset() {
_chatViewActive = false;
_stopLongPoll();
return super.reset();
}
void leaveChat(String fromToken) {
if ((innerState?.currentToken ?? '') != fromToken) return;
_chatViewActive = false;
@@ -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<void> 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;
@@ -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,
@@ -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() {
+5
View File
@@ -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
+9 -15
View File
@@ -53,15 +53,6 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
}).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<FilesUploadDialog> {
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);
@@ -135,19 +135,24 @@ Future<void> _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<void> _externalShareFlow(
}
}
// Called synchronously when the upload dialog pops, so the flow is entered
// before the upload step above releases it.
Future<void> _afterExternalFilesUploaded(
BuildContext context,
GetRoomResponseObject room,
List<String> 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
@@ -206,16 +206,21 @@ Future<void> _externalUploadFlow(
List<String> 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(
+5
View File
@@ -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);
+13
View File
@@ -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);
});
});
}