fixed stale state after logout and replayed or lost share intents
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
if FileManager.default.fileExists(atPath: dst.path) {
|
||||
try FileManager.default.removeItem(at: dst)
|
||||
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 {
|
||||
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;
|
||||
|
||||
@@ -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 (_) {
|
||||
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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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} '
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+29
-3
@@ -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,7 +146,10 @@ 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(
|
||||
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),
|
||||
@@ -135,10 +158,12 @@ abstract class LoadableHydratedBloc<
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void fetch() {
|
||||
log('Fetching data for ${TState.toString()}');
|
||||
gatherData()
|
||||
runInSession(
|
||||
() => gatherData()
|
||||
.catchError((Object e) {
|
||||
log('Error while fetching ${TState.toString()}: ${e.toString()}');
|
||||
// The bloc may have been closed before this async error landed;
|
||||
@@ -148,7 +173,8 @@ abstract class LoadableHydratedBloc<
|
||||
})
|
||||
.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)));
|
||||
runInSession(() {
|
||||
_loadCurrentWeek(startDate, endDate);
|
||||
_prefetchAdjacentWeeks(startDate, endDate);
|
||||
});
|
||||
}
|
||||
|
||||
void resetWeek() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (uploadTask.statusCode < 200 || uploadTask.statusCode > 299) {
|
||||
setState(_resetProgress);
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop();
|
||||
showHttpErrorCode(uploadTask.statusCode);
|
||||
} else {
|
||||
uploadetFilePaths.add(fullRemotePath);
|
||||
// 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) {
|
||||
_showUploadError(
|
||||
'Upload fehlgeschlagen für "$fileName" '
|
||||
'(Fehlercode ${uploadTask.statusCode}).',
|
||||
);
|
||||
return;
|
||||
}
|
||||
uploadetFilePaths.add(fullRemotePath);
|
||||
}
|
||||
|
||||
setState(_resetProgress);
|
||||
|
||||
@@ -135,6 +135,8 @@ Future<void> _externalShareFlow(
|
||||
PendingShare share,
|
||||
) async {
|
||||
if (share.hasFiles) {
|
||||
ShareIntentListener.instance.beginFlow();
|
||||
try {
|
||||
await ensureTalkShareFolder();
|
||||
if (!context.mounted) return;
|
||||
await pushScreen(
|
||||
@@ -148,6 +150,9 @@ Future<void> _externalShareFlow(
|
||||
_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(
|
||||
) async {
|
||||
ShareIntentListener.instance.beginFlow();
|
||||
try {
|
||||
await _runShareFlow(
|
||||
context,
|
||||
action: () =>
|
||||
shareFilesToChat(token: room.token, remoteFilePaths: uploadedRemotePaths),
|
||||
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,6 +206,8 @@ Future<void> _externalUploadFlow(
|
||||
List<String> targetPath,
|
||||
PendingShare share,
|
||||
) async {
|
||||
ShareIntentListener.instance.beginFlow();
|
||||
try {
|
||||
await pushScreen(
|
||||
context,
|
||||
withNavBar: false,
|
||||
@@ -216,6 +218,9 @@ Future<void> _externalUploadFlow(
|
||||
_afterExternalUploaded(context, targetPath, share),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
ShareIntentListener.instance.endFlow();
|
||||
}
|
||||
}
|
||||
|
||||
void _afterExternalUploaded(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user