merged develop

This commit is contained in:
2026-09-27 14:58:07 +02:00
188 changed files with 7474 additions and 2558 deletions
+219
View File
@@ -0,0 +1,219 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import '../session/session_manager.dart';
import '../utils/directory_size.dart';
/// One cached response: the raw JSON payload and when it was stored.
class CacheEntry {
final String json;
final int lastUpdate;
const CacheEntry({required this.json, required this.lastUpdate});
}
/// File-per-key store behind [RequestCache] and friends.
///
/// Replaces `localstore` for response caching: localstore reads and decodes
/// the entire collection synchronously on first access, keeps every document
/// in memory for the whole session and JSON-encodes payloads a second time.
/// Here every access is async and touches exactly one file, and the payload is
/// stored verbatim behind a one-line header (`<lastUpdate>\n<json>`).
///
/// Every account has its own directory; all access goes to the one of the
/// active account, in every isolate.
class CacheStore {
CacheStore._();
static final CacheStore instance = CacheStore._();
/// Payloads above this size are decoded on a background isolate.
static const int isolateDecodeThreshold = 64 * 1024;
static const _directoryPrefix = 'request_cache';
final Map<String, Future<Directory>> _dirs = {};
final Map<String, Future<void>> _writes = {};
/// Directory name for an account's storage [namespace]; null while no
/// account is active. The account from before multi-account support
/// (namespace '') keeps the original directory.
@visibleForTesting
static String directoryName(String? namespace) => switch (namespace) {
null => '$_directoryPrefix-signed-out',
'' => _directoryPrefix,
_ => '$_directoryPrefix-$namespace',
};
Future<Directory> _directory() {
final name = directoryName(SessionManager().activeAccount?.namespace);
return _dirs[name] ??= () async {
final base = await getApplicationCacheDirectory();
final dir = Directory('${base.path}/$name');
await dir.create(recursive: true);
return dir;
}();
}
Future<File> _file(String key) async =>
File('${(await _directory()).path}/${_safeName(key)}');
/// Keys are internal ids (`nc-chat-<token>`, `wd-folder-<md5>`); only path
/// separators would be a problem in a file name.
static String _safeName(String key) => key.replaceAll(RegExp(r'[/\\]'), '_');
Future<CacheEntry?> read(String key) async {
try {
final file = await _file(key);
await _writes[file.path];
return parse(await file.readAsString());
} on Object {
// Missing (PathNotFoundException) or unreadable: a cache miss.
return null;
}
}
/// All entries whose key starts with [prefix], read concurrently.
Future<Map<String, CacheEntry>> readAll({String prefix = ''}) async {
final keys = await this.keys(prefix: prefix);
final entries = await Future.wait(keys.map(read));
return {for (var i = 0; i < keys.length; i++) keys[i]: ?entries[i]};
}
Future<void> write(String key, String json) async {
// Resolved up front: a queued write must not follow an account switch
// into the next account's directory.
final file = await _file(key);
final path = file.path;
final previous = _writes[path] ?? Future<void>.value();
late final Future<void> current;
current = previous.then((_) => _write(file, json)).whenComplete(() {
if (identical(_writes[path], current)) _writes.remove(path);
});
return _writes[path] = current;
}
Future<void> _write(File file, String json) async {
try {
// Write-then-rename so a reader (or the widget background isolate)
// never sees a half-written file.
final tmp = File('${file.path}.tmp');
await tmp.writeAsString(
'${DateTime.now().millisecondsSinceEpoch}\n$json',
flush: true,
);
await tmp.rename(file.path);
} on Object catch (e) {
debugPrint('CacheStore.write(${file.path}) failed: $e');
}
}
Future<void> delete(String key) async {
try {
final file = await _file(key);
await _writes[file.path];
await file.delete();
} on Object {
// A missing or locked file is as good as deleted for a cache.
}
}
/// Empties the active account's cache.
Future<void> clear() async {
try {
final dir = await _directory();
if (dir.existsSync()) await dir.delete(recursive: true);
} on Object catch (e) {
debugPrint('CacheStore.clear failed: $e');
}
_dirs.clear();
}
/// Removes the cache of the account with [namespace], active or not.
Future<void> deleteNamespace(String namespace) async {
final name = directoryName(namespace);
try {
final base = await getApplicationCacheDirectory();
final dir = Directory('${base.path}/$name');
if (dir.existsSync()) await dir.delete(recursive: true);
} on Object catch (e) {
debugPrint('CacheStore.deleteNamespace failed: $e');
}
_dirs.removeWhere((key, _) => key == name);
}
/// All stored keys, optionally filtered by [prefix].
Future<List<String>> keys({String prefix = ''}) async {
try {
final dir = await _directory();
return [
await for (final entity in dir.list())
if (entity is File && !entity.path.endsWith('.tmp'))
if (entity.uri.pathSegments.last case final name
when name.startsWith(prefix))
name,
];
} on Object {
return const [];
}
}
/// Removes entries not written for [maxAge] from every account's cache,
/// judged by file mtime so nothing has to be read or decoded.
Future<void> deleteOlderThan(Duration maxAge) async {
try {
final base = await getApplicationCacheDirectory();
final cutoff = DateTime.now().subtract(maxAge);
await for (final dir in base.list()) {
if (dir is! Directory) continue;
final name = dir.uri.pathSegments.lastWhere((s) => s.isNotEmpty);
if (!name.startsWith(_directoryPrefix)) continue;
await for (final entity in dir.list()) {
if (entity is! File) continue;
// ignore: avoid_slow_async_io
final modified = (await entity.stat()).modified;
if (modified.isBefore(cutoff)) await entity.delete();
}
}
} on Object catch (e) {
debugPrint('CacheStore.deleteOlderThan failed: $e');
}
}
/// Total size of all entries in bytes.
Future<int> totalSize() async => directorySize(await _directory());
/// Parses the stored file format; public for tests.
static CacheEntry? parse(String raw) {
final newline = raw.indexOf('\n');
if (newline < 0) return null;
final lastUpdate = int.tryParse(raw.substring(0, newline));
if (lastUpdate == null) return null;
return CacheEntry(json: raw.substring(newline + 1), lastUpdate: lastUpdate);
}
/// Decodes a cached payload, off the UI isolate when it is large.
static Future<Map<String, dynamic>> decode(String json) =>
json.length > isolateDecodeThreshold
? compute(_decodeMap, json)
: Future.value(_decodeMap(json));
static Map<String, dynamic> _decodeMap(String json) =>
jsonDecode(json) as Map<String, dynamic>;
/// Deletes the directory the old localstore-based cache lived in. It is
/// never read again and would otherwise linger with up to 200 days of data.
static Future<void> deleteLegacyLocalstoreCache() async {
try {
final docs = await getApplicationDocumentsDirectory();
final legacy = Directory('${docs.path}/MarianumMobile');
if (legacy.existsSync()) await legacy.delete(recursive: true);
} on Object catch (e) {
debugPrint('Legacy cache cleanup failed: $e');
}
}
}
@@ -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',
);
}
@@ -4,6 +4,7 @@ import 'package:http/http.dart' as http;
import '../../../model/endpoint_data.dart';
import '../../errors/server_exception.dart';
import '../../http_errors.dart';
import '../nextcloud_ocs.dart';
import 'autocomplete_response.dart';
@@ -30,7 +31,12 @@ class AutocompleteApi {
'limit': '10',
},
);
final response = await http.get(uri, headers: NextcloudOcs.headers());
final response = (await sendGuarded(
'Autocomplete $uri',
() => http
.get(uri, headers: NextcloudOcs.headers())
.timeout(const Duration(seconds: 20)),
))!;
if (response.statusCode != HttpStatus.ok) {
throw ServerException(
statusCode: response.statusCode,
@@ -4,6 +4,7 @@ import 'package:http/http.dart' as http;
import '../../../api_params.dart';
import '../../../api_response.dart';
import '../room/get_room_response.dart';
import '../talk_api.dart';
/// Small POST/DELETE-only Talk endpoints that have no response payload.
@@ -105,3 +106,21 @@ class DeleteRoomAvatar extends TalkApi {
Map<String, String>? headers,
) => http.delete(uri, headers: headers);
}
class SetNotificationLevel extends TalkApi {
final String chatToken;
final GetRoomResponseObjectParticipantNotificationLevel level;
SetNotificationLevel(this.chatToken, this.level)
: super('v4/room/$chatToken/notify', null);
@override
ApiResponse? assemble(String raw) => null;
@override
Future<http.Response> request(
Uri uri,
ApiParams? body,
Map<String, String>? headers,
) => http.post(uri, headers: headers, body: {'level': '${level.apiValue}'});
}
@@ -17,7 +17,7 @@ class GetChatCache extends SimpleCache<GetChatResponse> {
lookIntoFuture: GetChatParamsSwitch.off,
setReadMarker: GetChatParamsSwitch.on,
// Small initial page; also the per-chat offline snapshot written to
// localstore. Older messages are paged in on scroll-up via
// the request cache. Older messages are paged in on scroll-up via
// GetChatHistory. Keep in sync with ChatBloc's _kInitialPageSize.
limit: 50,
),
@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../../../errors/server_exception.dart';
@@ -45,8 +46,10 @@ class GetChatHistory {
final status = response.statusCode;
if (status == 304) return null;
if (status >= 200 && status < 300) {
return GetChatResponse.fromJson(NextcloudOcs.decode(response.body))
..headers = response.headers;
// A page holds up to 200 messages and lands while the user is scrolling;
// decoding it on the UI isolate stalls the fling.
final parsed = await compute(_parseChatResponse, response.body);
return parsed..headers = response.headers;
}
throw ServerException(
statusCode: status,
@@ -54,3 +57,6 @@ class GetChatHistory {
);
}
}
GetChatResponse _parseChatResponse(String body) =>
GetChatResponse.fromJson(NextcloudOcs.decode(body));
+10 -2
View File
@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../../nextcloud_ocs.dart';
@@ -10,8 +11,12 @@ class GetRoom extends TalkApi<GetRoomResponse> {
GetRoom(this.params) : super('v4/room', null, getParameters: params.toJson());
@override
GetRoomResponse assemble(String raw) =>
GetRoomResponse.fromJson(NextcloudOcs.decode(raw));
GetRoomResponse assemble(String raw) => _parseRooms(raw);
// The room list with status is 100–250 KB and polled every 15–60 s.
@override
Future<GetRoomResponse> assembleAsync(String raw) =>
compute(_parseRooms, raw);
@override
Future<http.Response> request(
@@ -20,3 +25,6 @@ class GetRoom extends TalkApi<GetRoomResponse> {
Map<String, String>? headers,
) => http.get(uri, headers: headers);
}
GetRoomResponse _parseRooms(String raw) =>
GetRoomResponse.fromJson(NextcloudOcs.decode(raw));
@@ -130,6 +130,12 @@ enum GetRoomResponseObjectParticipantNotificationLevel {
neverNotify,
}
extension NotificationLevelApiValue
on GetRoomResponseObjectParticipantNotificationLevel {
int get apiValue =>
_$GetRoomResponseObjectParticipantNotificationLevelEnumMap[this]!;
}
enum GetRoomResponseObjectMessageActorType {
@JsonValue('deleted_users')
deletedUsers,
@@ -0,0 +1,27 @@
import 'get_room_response.dart';
typedef NotificationLevel = GetRoomResponseObjectParticipantNotificationLevel;
/// The levels a user can pick in the UI; [NotificationLevel.defaultLevel]
/// is only ever reported by the server, never chosen.
const selectableNotificationLevels = [
NotificationLevel.alwaysNotify,
NotificationLevel.notifyOnMention,
NotificationLevel.neverNotify,
];
extension RoomNotificationLevel on GetRoomResponseObject {
/// Resolves "default" the way Spreed does: 1:1 chats notify on every
/// message, group chats only on mentions (`default_group_notification`).
NotificationLevel get effectiveNotificationLevel {
if (notificationLevel != NotificationLevel.defaultLevel) {
return notificationLevel;
}
return type == GetRoomResponseObjectConversationType.oneToOne
? NotificationLevel.alwaysNotify
: NotificationLevel.notifyOnMention;
}
bool get isMuted =>
effectiveNotificationLevel == NotificationLevel.neverNotify;
}
@@ -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;
+10 -2
View File
@@ -12,6 +12,10 @@ import '../nextcloud_ocs.dart';
import 'talk_error.dart';
abstract class TalkApi<T extends ApiResponse?> {
// package:http has no timeout of its own; a request stuck on a dead
// connection (e.g. one started right before a suspend) would spin forever.
static const Duration _timeout = Duration(seconds: 30);
String path;
ApiParams? body;
Map<String, String>? headers;
@@ -26,6 +30,10 @@ abstract class TalkApi<T extends ApiResponse?> {
);
T assemble(String raw);
/// Override to parse large payloads off the UI isolate (e.g. via `compute`
/// with a top-level parser); defaults to the synchronous [assemble].
Future<T> assembleAsync(String raw) async => assemble(raw);
Future<T> run() async {
final endpoint = NextcloudOcs.uri(
'apps/spreed/api/$path',
@@ -35,7 +43,7 @@ abstract class TalkApi<T extends ApiResponse?> {
final data = await sendGuarded(
'Talk $endpoint',
() => request(endpoint, body, mergedHeaders),
() => request(endpoint, body, mergedHeaders)?.timeout(_timeout),
);
if (data == null) {
throw const NetworkException(
@@ -65,7 +73,7 @@ abstract class TalkApi<T extends ApiResponse?> {
}
try {
final assembled = assemble(data.body);
final assembled = await assembleAsync(data.body);
assembled?.headers = data.headers;
return assembled;
} catch (e) {
@@ -29,6 +29,19 @@ class CacheableFile {
/// file/folder in the list. Nullable so older cached entries decode fine.
bool? isSharedWithMe;
/// Lower-cased [name] for sorting/filtering, cached because comparators
/// call it for every comparison (name is mutable, hence the identity check).
String get lowerName {
if (!identical(_lowerNameFor, name)) {
_lowerNameFor = name;
_lowerName = name.toLowerCase();
}
return _lowerName;
}
String? _lowerNameFor;
String _lowerName = '';
CacheableFile({
required this.path,
required this.isDirectory,
@@ -1,9 +1,9 @@
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:localstore/localstore.dart';
import '../../../../../utils/cache_invalidation_bus.dart';
import '../../../../cache_store.dart';
import '../../../../request_cache.dart';
import 'list_files.dart';
import 'list_files_params.dart';
@@ -43,14 +43,11 @@ class ListFilesCache extends SimpleCache<ListFilesResponse> {
/// (slow) root listing is already populated by the time the user
/// navigates to the Files module.
///
/// No-ops when a cached root payload is already present in localstore —
/// No-ops when a cached root payload is already present in the cache —
/// the regular TTL handling in [RequestCache] takes over from there.
static Future<void> prefetchRootListing() async {
const rootPath = '';
final cached = await Localstore.instance
.collection(RequestCache.collection)
.doc(_documentId(rootPath))
.get();
final cached = await CacheStore.instance.read(_documentId(rootPath));
if (cached != null) return;
// Drive the same code path as a regular fetch so the result lands in
// the cache; we don't care about the in-memory callback here.
@@ -69,10 +66,7 @@ class ListFilesCache extends SimpleCache<ListFilesResponse> {
/// `_FilesView` for that path via [CacheInvalidationBus] so it refetches
/// even while it is sitting in the background of the navigation stack.
static Future<void> invalidate(String path) async {
await Localstore.instance
.collection(RequestCache.collection)
.doc(_documentId(path))
.delete();
await CacheStore.instance.delete(_documentId(path));
CacheInvalidationBus.notifyListFiles(path);
}
}
@@ -18,6 +18,8 @@ abstract class WebdavApi<T> {
/// changes (app password minted/renewed, account switch) so it never keeps
/// authenticating with stale credentials.
static Future<WebDavClient> get webdav {
// Keyed by user too: two accounts may share a password (no app password
// minted), and the client would keep the previous login name.
final nextcloud = SessionManager().requireNextcloud();
final secret = '${nextcloud.username}:${nextcloud.secret}';
if (_webdav == null || _webdavSecret != secret) {
@@ -105,17 +105,29 @@ class MarianumConnectAuthInterceptor extends Interceptor {
Future<bool> _performReLogin() async {
final session = SessionManager().current;
if (session is! CredentialSession) return false;
final username = session.username;
// 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 SessionManager().readStoredUsername() != username) return false;
try {
await _loginClient.run(
username: session.username,
username: username,
password: session.password,
tokenName: await DeviceTokenName.resolve(),
);
return true;
} catch (_) {
await _tokenStorage.clear();
if (await SessionManager().readStoredUsername() == username) {
await _tokenStorage.clear();
}
return false;
}
final stored = await SessionManager().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,6 +19,7 @@ class SessionValidator {
// The probes use their own dio (bypassing the demo interceptor), so a demo
// session must be skipped or its missing token would 401 into a logout.
if (session == null || session.isDemo) return;
final epoch = SessionManager().sessionEpoch;
try {
switch (session) {
case CredentialSession(:final username, :final password):
@@ -28,6 +29,9 @@ class SessionValidator {
}
} on AuthException catch (e) {
if (e.statusCode != 401) return;
// The probed account is no longer the active one; the 401 must not
// sign out whoever took over meanwhile.
if (!SessionManager().isCurrentSession(epoch)) return;
log('MC: stored session rejected — forcing re-login');
final next = await SessionLifecycle.signOut(
notice: switch (session) {
@@ -16,6 +16,16 @@ class MarianumConnectApi {
static Dio dio() => _instance;
/// Drops the pooled keep-alive sockets. After a suspend the OS or a NAT
/// has usually killed them already, but dart:io only notices once a request
/// is written into one — which then hangs until [_receiveTimeout].
/// In-flight requests finish on the old adapter.
static void resetConnections() {
final stale = _instance.httpClientAdapter;
_instance.httpClientAdapter = HttpClientAdapter();
stale.close();
}
/// A fresh dio with the standard JSON options but no interceptors — used by
/// the auth queries (login/verify) that must bypass the bearer/demo
/// interceptors to avoid a re-auth loop.
@@ -3,41 +3,71 @@ import 'dart:convert';
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:package_info_plus/package_info_plus.dart';
import '../../../../push/push_registration.dart';
import '../../../../push/push_registration_store.dart';
import '../../../../push/push_registration_type.dart';
import '../../marianumconnect_query.dart';
import 'telemetry_device_id.dart';
typedef _PushState = ({
bool enabled,
AuthorizationStatus? permission,
String? pushDeviceIdentifier,
});
/// Sends a telemetry heartbeat to MarianumConnect (`POST me/telemetry`) —
/// upserts the stable install id, platform, app version and device info. Sent
/// on app start, again once push registration completes that session (so a
/// fresh registration isn't under-reported until the next launch), and on
/// resume after the app spent more than 15 minutes in the background.
/// on app start, on resume and once push registration completes, throttled to
/// one per 15 minutes unless the push state changed in between.
/// Bearer-authenticated via the shared dio interceptor. Replaces the legacy
/// mhsl.eu `server/userIndex/update` call.
class TelemetryHeartbeat extends MarianumConnectQuery {
TelemetryHeartbeat({super.dio});
static const Duration _interval = Duration(minutes: 15);
// Wall-clock throttle rather than Debouncer.throttle: a Timer does not tick
// reliably while the app is suspended, so the window would still be open on
// the resume it is supposed to let through.
static DateTime? _lastSentAt;
static _PushState? _lastPushState;
/// Fire-and-forget: schedules a heartbeat and swallows any error, so a failed
/// send never disrupts app start. Used from the app shell's initState and
/// lifecycle handler, and re-emitted once push registration completes (see
/// `_MainState._syncPush`).
/// send never disrupts app start. A changed push state (toggle, OS
/// permission — e.g. back from the system settings — or a fresh
/// registration) bypasses the throttle: the server picks visible vs. silent
/// delivery from it, so it must not wait for the next window.
static void report({required bool notificationsEnabled}) {
unawaited(
TelemetryHeartbeat()
.send(notificationsEnabled: notificationsEnabled)
.catchError((Object _) {}),
);
unawaited(_report(notificationsEnabled).catchError((Object _) {}));
}
Future<void> send({required bool notificationsEnabled}) => guard(() async {
static Future<void> _report(bool enabled) async {
final pushState = (
enabled: enabled,
permission: await PushRegistration.osPermissionStatus(),
pushDeviceIdentifier: await const PushRegistrationStore()
.deviceIdentifier(PushRegistrationType.general),
);
final now = DateTime.now();
final last = _lastSentAt;
if (last != null &&
now.difference(last) < _interval &&
pushState == _lastPushState) {
return;
}
// Claimed before sending so overlapping triggers don't both go out.
_lastSentAt = now;
_lastPushState = pushState;
await TelemetryHeartbeat()._send(pushState);
}
Future<void> _send(_PushState push) => guard(() async {
final info = DeviceInfoPlugin();
final package = await PackageInfo.fromPlatform();
final deviceIdentifier = await TelemetryDeviceId.resolve();
final pushDeviceIdentifier = await const PushRegistrationStore()
.deviceIdentifier(PushRegistrationType.general);
var platform = 'unknown';
String? deviceModel;
@@ -62,11 +92,12 @@ class TelemetryHeartbeat extends MarianumConnectQuery {
data: {
'deviceIdentifier': deviceIdentifier,
// `pushDeviceIdentifier` reflects a *completed* registration and is
// absent until it lands; `pushEnabled` carries the user's intent
// (the notification toggle) so the backend can tell "user wants push"
// apart from "registration not finished yet".
'pushDeviceIdentifier': ?pushDeviceIdentifier,
'pushEnabled': notificationsEnabled,
// absent until it lands; `pushEnabled` carries the user's intent (the
// toggle). The server combines it with `osPermission` into the
// visible-vs-silent delivery decision.
'pushDeviceIdentifier': ?push.pushDeviceIdentifier,
'pushEnabled': push.enabled,
'osPermission': ?osPermissionWireValue(push.permission),
'platform': platform,
'appVersion': package.version,
'appBuild': int.tryParse(package.buildNumber),
@@ -77,3 +108,13 @@ class TelemetryHeartbeat extends MarianumConnectQuery {
);
});
}
/// Wire value of the OS notification permission for the heartbeat.
String? osPermissionWireValue(AuthorizationStatus? status) => switch (status) {
AuthorizationStatus.authorized => 'granted',
AuthorizationStatus.provisional => 'provisional',
AuthorizationStatus.denied ||
AuthorizationStatus.deniedPermanently => 'denied',
AuthorizationStatus.notDetermined => 'notDetermined',
null => null,
};
@@ -1,6 +1,6 @@
import 'dart:developer';
import 'package:localstore/localstore.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../../../session/session.dart';
import '../../../../session/session_manager.dart';
@@ -21,9 +21,7 @@ import 'timetable_custom_events_add.dart';
/// failure only sees the events that were not yet moved, so nothing is
/// duplicated. The flag is set only once MHSL reports no remaining events.
class CustomEventsMigration {
static const String _collection = 'MarianumMobile';
static const String _document = 'customEventsMigration';
static const String _doneKey = 'migratedToMc';
static const String _doneKey = 'customEventsMigratedToMc';
const CustomEventsMigration._();
@@ -34,18 +32,23 @@ class CustomEventsMigration {
if (session is! CredentialSession || session.isDemo) return;
if (await _isDone()) return;
final epoch = SessionManager().sessionEpoch;
try {
final response = await GetCustomTimetableEvent(
GetCustomTimetableEventParams(session.legacyUserSecret),
).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 (!SessionManager().isCurrentSession(epoch)) return;
await TimetableCustomEventsAdd().run(event);
await RemoveCustomTimetableEvent(
RemoveCustomTimetableEventParams(event.id),
).run();
}
if (!SessionManager().isCurrentSession(epoch)) return;
await _markDone();
log(
'Custom events migration: moved ${response.events.length} event(s) to Marianum-Connect.',
@@ -57,17 +60,11 @@ class CustomEventsMigration {
}
}
static Future<bool> _isDone() async {
final data = await Localstore.instance
.collection(_collection)
.doc(_document)
.get();
return data != null && data[_doneKey] == true;
}
// SharedPreferences, like the old cache document, is cleared on sign-out,
// so the next account runs its own migration.
static Future<bool> _isDone() async =>
(await SharedPreferences.getInstance()).getBool(_doneKey) ?? false;
static Future<void> _markDone() async {
await Localstore.instance.collection(_collection).doc(_document).set({
_doneKey: true,
});
}
static Future<void> _markDone() async =>
(await SharedPreferences.getInstance()).setBool(_doneKey, true);
}
@@ -10,12 +10,17 @@ class McTimetableTeacher {
final String displayName;
final String? originalShortName;
final String? originalDisplayName;
// Webuntis element ids; null from servers that predate them.
final int? id;
final int? originalId;
McTimetableTeacher({
required this.shortName,
required this.displayName,
this.originalShortName,
this.originalDisplayName,
this.id,
this.originalId,
});
factory McTimetableTeacher.fromJson(Map<String, dynamic> json) =>
@@ -45,6 +50,8 @@ class McTimetableEntry {
final String? substitutionText;
final String? lessonText;
final String? infoText;
// Index-aligned with [classNames]; null from servers that predate it.
final List<int>? classIds;
McTimetableEntry({
required this.id,
@@ -60,6 +67,7 @@ class McTimetableEntry {
required this.substitutionText,
required this.lessonText,
required this.infoText,
this.classIds,
});
factory McTimetableEntry.fromJson(Map<String, dynamic> json) =>
@@ -68,10 +76,17 @@ class McTimetableEntry {
/// Combines the calendar date with the hour/minute portion of [startTime]
/// (which carries a 1970 placeholder date) into a real DateTime.
DateTime get startDateTime =>
DateTime get startDateTime => _startDateTime;
DateTime get endDateTime => _endDateTime;
// Cached (private, so json_serializable ignores them): sorting and lesson
// merging read these per comparison, and every local DateTime construction
// does a timezone lookup.
late final DateTime _startDateTime =
DateTime(date.year, date.month, date.day, startTime.hour, startTime.minute);
DateTime get endDateTime =>
late final DateTime _endDateTime =
DateTime(date.year, date.month, date.day, endTime.hour, endTime.minute);
static DateTime _dateFromJson(String raw) => DateTime.parse(raw);
@@ -12,6 +12,8 @@ McTimetableTeacher _$McTimetableTeacherFromJson(Map<String, dynamic> json) =>
displayName: json['displayName'] as String,
originalShortName: json['originalShortName'] as String?,
originalDisplayName: json['originalDisplayName'] as String?,
id: (json['id'] as num?)?.toInt(),
originalId: (json['originalId'] as num?)?.toInt(),
);
Map<String, dynamic> _$McTimetableTeacherToJson(McTimetableTeacher instance) =>
@@ -20,6 +22,8 @@ Map<String, dynamic> _$McTimetableTeacherToJson(McTimetableTeacher instance) =>
'displayName': instance.displayName,
'originalShortName': instance.originalShortName,
'originalDisplayName': instance.originalDisplayName,
'id': instance.id,
'originalId': instance.originalId,
};
McTimetableEntry _$McTimetableEntryFromJson(Map<String, dynamic> json) =>
@@ -43,6 +47,9 @@ McTimetableEntry _$McTimetableEntryFromJson(Map<String, dynamic> json) =>
substitutionText: json['substitutionText'] as String?,
lessonText: json['lessonText'] as String?,
infoText: json['infoText'] as String?,
classIds: (json['classIds'] as List<dynamic>?)
?.map((e) => (e as num).toInt())
.toList(),
);
Map<String, dynamic> _$McTimetableEntryToJson(McTimetableEntry instance) =>
@@ -60,6 +67,7 @@ Map<String, dynamic> _$McTimetableEntryToJson(McTimetableEntry instance) =>
'substitutionText': instance.substitutionText,
'lessonText': instance.lessonText,
'infoText': instance.infoText,
'classIds': instance.classIds,
};
TimetableGetWeekResponse _$TimetableGetWeekResponseFromJson(
+50 -35
View File
@@ -1,10 +1,11 @@
import 'dart:async';
import 'dart:convert';
import 'package:localstore/localstore.dart';
import '../session/session_manager.dart';
import 'api_response.dart';
import 'cache_store.dart';
import 'errors/parse_exception.dart';
import 'errors/stale_session_exception.dart';
abstract class RequestCache<T extends ApiResponse?> {
static const int cacheNothing = 0;
@@ -12,12 +13,10 @@ abstract class RequestCache<T extends ApiResponse?> {
static const int cacheHour = 60 * 60;
static const int cacheDay = 60 * 60 * 24;
static String collection = 'MarianumMobile';
int maxCacheTime;
void Function(T)? onUpdate;
/// Called only when [start] finds a cached payload in localstore. Use this
/// Called only when [start] finds a cached payload in the [CacheStore]. Use this
/// (instead of [onUpdate]) when callers need to distinguish stale-but-fast
/// cache hits from authoritative network responses.
void Function(T)? onCacheData;
@@ -48,42 +47,57 @@ abstract class RequestCache<T extends ApiResponse?> {
static void ignore(Exception e) {}
Future<void> start(String document) async {
final epoch = SessionManager().sessionEpoch;
try {
final tableData = await Localstore.instance
.collection(collection)
.doc(document)
.get();
if (tableData != null) {
final cached = onLocalData(tableData['json'] as String);
final entry = await CacheStore.instance.read(document);
var lastUpdate = entry?.lastUpdate ?? 0;
T? cached;
if (entry != null) {
try {
cached = fromCacheJson(await CacheStore.decode(entry.json));
} on Object {
// A payload from an older model version: treat it as a miss.
await CacheStore.instance.delete(document);
lastUpdate = 0;
}
}
if (cached != null) {
onUpdate?.call(cached);
onCacheData?.call(cached);
}
final lastUpdate = (tableData?['lastupdate'] as num?) ?? 0;
if (DateTime.now().millisecondsSinceEpoch - (maxCacheTime * 1000) <
lastUpdate) {
if (renew == null || !renew!) return;
}
try {
final newValue = await onLoad();
onUpdate?.call(newValue);
onNetworkData?.call(newValue);
unawaited(
Localstore.instance.collection(collection).doc(document).set({
'json': jsonEncode(newValue),
'lastupdate': DateTime.now().millisecondsSinceEpoch,
}),
);
} on Exception catch (e) {
onError(e);
}
await _load(document, epoch, lastUpdate: lastUpdate);
} finally {
if (!_ready.isCompleted) _ready.complete();
}
}
T onLocalData(String json);
Future<void> _load(
String document,
int epoch, {
required int lastUpdate,
}) async {
if (DateTime.now().millisecondsSinceEpoch - (maxCacheTime * 1000) <
lastUpdate) {
if (renew == null || !renew!) return;
}
try {
final newValue = await onLoad();
// The cache is shared, so a late response of a signed-out
// account would otherwise be cached for the next one.
if (!SessionManager().isCurrentSession(epoch)) {
onError(const StaleSessionException());
return;
}
onUpdate?.call(newValue);
onNetworkData?.call(newValue);
unawaited(CacheStore.instance.write(document, jsonEncode(newValue)));
} on Exception catch (e) {
onError(e);
}
}
T fromCacheJson(Map<String, dynamic> json);
Future<T> onLoad();
}
@@ -117,8 +131,7 @@ class SimpleCache<T extends ApiResponse?> extends RequestCache<T> {
Future<T> onLoad() => _loader();
@override
T onLocalData(String json) =>
_fromJson(jsonDecode(json) as Map<String, dynamic>);
T fromCacheJson(Map<String, dynamic> json) => _fromJson(json);
}
/// Captures the latest cache payload (cached or network) and rethrows the
@@ -141,8 +154,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