improved performance and battery usage on older devices
This commit is contained in:
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+8
-16
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user