improved performance and battery usage on older devices

This commit is contained in:
2026-09-25 23:58:08 +02:00
parent ed8e52f475
commit 56a497985b
70 changed files with 1631 additions and 621 deletions
+185
View File
@@ -0,0 +1,185 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.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>`).
class CacheStore {
CacheStore._();
static final CacheStore instance = CacheStore._();
/// Payloads above this size are decoded on a background isolate.
static const int isolateDecodeThreshold = 64 * 1024;
Future<Directory>? _dir;
final Map<String, Future<void>> _writes = {};
Future<Directory> _directory() => _dir ??= () async {
final base = await getApplicationCacheDirectory();
final dir = Directory('${base.path}/request_cache');
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 {
await _writes[key];
return parse(await (await _file(key)).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) {
final previous = _writes[key] ?? Future<void>.value();
late final Future<void> current;
current = previous.then((_) => _write(key, json)).whenComplete(() {
if (identical(_writes[key], current)) _writes.remove(key);
});
return _writes[key] = current;
}
Future<void> _write(String key, String json) async {
try {
final file = await _file(key);
// 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($key) failed: $e');
}
}
Future<void> delete(String key) async {
try {
await _writes[key];
await (await _file(key)).delete();
} on Object {
// A missing or locked file is as good as deleted for a 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');
}
_dir = null;
}
/// 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], judged by file mtime so nothing
/// has to be read or decoded.
Future<void> deleteOlderThan(Duration maxAge) async {
try {
final dir = await _directory();
final cutoff = DateTime.now().subtract(maxAge);
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 {
try {
final dir = await _directory();
var sum = 0;
await for (final entity in dir.list()) {
if (entity is File) sum += await entity.length();
}
return sum;
} on Object {
return 0;
}
}
/// 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');
}
}
}
@@ -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));
+5 -1
View File
@@ -30,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',
@@ -69,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);
}
}
@@ -1,6 +1,6 @@
import 'dart:developer';
import 'package:localstore/localstore.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../../../model/account_data.dart';
import '../../../demo/demo_mode.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._();
@@ -57,17 +55,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);
}
@@ -76,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);
+44 -40
View File
@@ -1,10 +1,9 @@
import 'dart:async';
import 'dart:convert';
import 'package:localstore/localstore.dart';
import '../model/account_data.dart';
import 'api_response.dart';
import 'cache_store.dart';
import 'errors/parse_exception.dart';
import 'errors/stale_session_exception.dart';
@@ -14,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;
@@ -52,47 +49,55 @@ abstract class RequestCache<T extends ApiResponse?> {
Future<void> start(String document) async {
final epoch = AccountData().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();
// 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(
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 (!AccountData().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();
}
@@ -126,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