diff --git a/lib/api/cache_store.dart b/lib/api/cache_store.dart index 169540c..52b576a 100644 --- a/lib/api/cache_store.dart +++ b/lib/api/cache_store.dart @@ -52,10 +52,16 @@ class CacheStore { Future _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; + try { + final base = await getApplicationCacheDirectory(); + final dir = Directory('${base.path}/$name'); + await dir.create(recursive: true); + return dir; + } on Object { + // Not memoized (disk full at launch): the next call tries again. + unawaited(_dirs.remove(name)); + rethrow; + } }(); } diff --git a/lib/api/errors/error_mapper.dart b/lib/api/errors/error_mapper.dart index 1925c1a..225aed6 100644 --- a/lib/api/errors/error_mapper.dart +++ b/lib/api/errors/error_mapper.dart @@ -54,11 +54,61 @@ AppException? _dioToAppException(DioException error) { } } +final RegExp _davException = RegExp( + r'\s*([^<]*?)\s*', +); +final RegExp _davMessage = RegExp(r'\s*([^<]*?)\s*'); + +/// The Sabre DAV error body of a failed WebDAV request: the exception class +/// (without its `Sabre\DAV\Exception\` namespace) and the server's message. +/// The class says what actually went wrong where the status is ambiguous — +/// MKCOL on an existing folder is a plain 405. +({String exception, String message})? parseDavError(String body) { + final exception = _davException.firstMatch(body)?.group(1); + if (exception == null || exception.isEmpty) return null; + return ( + exception: exception.split('\\').last, + message: _davMessage.firstMatch(body)?.group(1) ?? '', + ); +} + +AppException? _davToAppException( + int status, + ({String exception, String message}) dav, + String detail, +) { + final userMessage = switch (dav.exception) { + 'MethodNotAllowed' when status == 405 => + 'Ein Ordner oder eine Datei mit diesem Namen existiert bereits.', + 'PreconditionFailed' => + 'Am Zielort existiert bereits ein Eintrag mit diesem Namen.', + 'Conflict' => 'Der übergeordnete Ordner existiert nicht mehr.', + 'Locked' => + 'Der Eintrag wird gerade bearbeitet. Bitte versuche es gleich erneut.', + 'InsufficientStorage' => + 'Der verfügbare Speicherplatz reicht dafür nicht aus.', + _ => null, + }; + if (userMessage == null) return null; + return ServerException( + statusCode: status, + userMessage: userMessage, + technicalDetails: detail, + allowRetry: dav.exception == 'Locked', + ); +} + /// The nextcloud/dynamite clients throw [DynamiteApiException] on non-2xx. /// Its toString() dumps every response header, so the details keep only the /// status plus a trimmed body preview (same format as the Talk API errors). AppException _dynamiteToAppException(DynamiteApiException error) { final status = error.statusCode; + final dav = parseDavError(error.body); + if (dav != null) { + final detail = 'HTTP $status ${dav.exception}: ${dav.message}'; + return _davToAppException(status, dav, detail) ?? + exceptionForStatus(status, detail); + } final preview = previewBody(error.body); final detail = preview.isEmpty ? 'HTTP $status' diff --git a/lib/api/errors/server_exception.dart b/lib/api/errors/server_exception.dart index 8cab2b5..d56065a 100644 --- a/lib/api/errors/server_exception.dart +++ b/lib/api/errors/server_exception.dart @@ -7,6 +7,7 @@ class ServerException extends AppException { required this.statusCode, String? userMessage, super.technicalDetails, + super.allowRetry, }) : super( // Status code intentionally not in the user message — it lives in // technicalDetails behind "Details anzeigen". @@ -14,6 +15,5 @@ class ServerException extends AppException { userMessage ?? 'Der Server konnte die Anfrage gerade nicht verarbeiten. ' 'Bitte versuche es in einem Moment erneut.', - allowRetry: true, ); } diff --git a/lib/api/marianumconnect/auth/token_storage.dart b/lib/api/marianumconnect/auth/token_storage.dart index 048bcec..c04e7aa 100644 --- a/lib/api/marianumconnect/auth/token_storage.dart +++ b/lib/api/marianumconnect/auth/token_storage.dart @@ -1,6 +1,7 @@ import 'package:dio/dio.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import '../../../storage/keychain_write.dart'; import '../../errors/auth_exception.dart'; /// `first_unlock` accessibility so the token can be read during background @@ -52,9 +53,9 @@ class MarianumConnectTokenStorage { required String tokenId, required DateTime? expiresAt, }) async { - await _storage.write(key: _tokenKey, value: token); - await _storage.write(key: _tokenIdKey, value: tokenId); - await _storage.write( + await _storage.writeRetrying(key: _tokenKey, value: token); + await _storage.writeRetrying(key: _tokenIdKey, value: tokenId); + await _storage.writeRetrying( key: _expiresAtKey, value: expiresAt?.toIso8601String() ?? '', ); @@ -75,7 +76,7 @@ class MarianumConnectTokenStorage { if (value == null) { await _storage.delete(key: key); } else { - await _storage.write(key: key, value: value); + await _storage.writeRetrying(key: key, value: value); } } } diff --git a/lib/api/marianumconnect/queries/telemetry_heartbeat/telemetry_device_id.dart b/lib/api/marianumconnect/queries/telemetry_heartbeat/telemetry_device_id.dart index ad89dee..71b4ec9 100644 --- a/lib/api/marianumconnect/queries/telemetry_heartbeat/telemetry_device_id.dart +++ b/lib/api/marianumconnect/queries/telemetry_heartbeat/telemetry_device_id.dart @@ -1,5 +1,6 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import '../../../../storage/keychain_write.dart'; import '../../../../utils/random_id.dart'; /// A stable, anonymous per-install identifier for telemetry. Generated once on @@ -22,7 +23,7 @@ class TelemetryDeviceId { return existing; } final generated = randomHexId(); - await _storage.write(key: _key, value: generated); + await _storage.writeRetrying(key: _key, value: generated); _cached = generated; return generated; } diff --git a/lib/notification/notification_service.dart b/lib/notification/notification_service.dart index bdd4953..861bb60 100644 --- a/lib/notification/notification_service.dart +++ b/lib/notification/notification_service.dart @@ -17,9 +17,11 @@ class NotificationService { Future initializeNotifications() async { // Dedicated monochrome status-bar icon: launcher mipmaps are unusable as // small icons because Android renders only their alpha silhouette (a - // solid circle in the status bar). + // solid circle in the status bar). Bare resource name: the plugin resolves + // it via getIdentifier(name, "drawable", ...), and not every ROM strips a + // leading "@drawable/" there (reported as invalid_icon on some devices). const androidSettings = AndroidInitializationSettings( - '@drawable/ic_stat_notification', + 'ic_stat_notification', ); // iOS Talk category mirrors the Android inline reply + mark-as-read actions diff --git a/lib/push/chat_thread_store.dart b/lib/push/chat_thread_store.dart index 8a692c5..ed1fbc4 100644 --- a/lib/push/chat_thread_store.dart +++ b/lib/push/chat_thread_store.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:flutter/foundation.dart'; import 'package:localstore/localstore.dart'; /// Maximum messages kept per chat thread — enough for a messenger-style @@ -190,8 +191,15 @@ class ChatThreadStore { DocumentRef _doc(String chatToken) => _db.collection(_collection).doc(docIdForToken(chatToken)); - Future _write(String chatToken, List messages) => - _doc(chatToken).set({ + /// Best effort: a full disk must not keep the notification from being + /// shown; the thread just restarts from the next push. + Future _write(String chatToken, List messages) async { + try { + await _doc(chatToken).set({ 'messages': [for (final m in messages) m.toJson()], }); + } on Object catch (e) { + debugPrint('ChatThreadStore.write($chatToken) failed: $e'); + } + } } diff --git a/lib/push/nid_store.dart b/lib/push/nid_store.dart index 20f1ec1..58589c3 100644 --- a/lib/push/nid_store.dart +++ b/lib/push/nid_store.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:localstore/localstore.dart'; /// A rendered notification's bookkeeping, keyed by the Nextcloud notification @@ -40,8 +41,15 @@ class NidStore { NidStore({Localstore? db}) : _db = db ?? Localstore.instance; - Future put(NidEntry entry) => - _db.collection(_collection).doc('${entry.nid}').set(entry.toJson()); + /// Best effort: a full disk must not keep the notification itself from + /// being shown — only a later delete-push would then miss the mapping. + Future put(NidEntry entry) async { + try { + await _db.collection(_collection).doc('${entry.nid}').set(entry.toJson()); + } on Object catch (e) { + debugPrint('NidStore.put(${entry.nid}) failed: $e'); + } + } Future get(int nid) async { final data = await _db.collection(_collection).doc('$nid').get(); diff --git a/lib/push/push_secure_storage.dart b/lib/push/push_secure_storage.dart index 9834edf..16f7be8 100644 --- a/lib/push/push_secure_storage.dart +++ b/lib/push/push_secure_storage.dart @@ -1,4 +1,5 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import '../storage/keychain_write.dart'; /// Keychain access group shared between the Runner and the (Phase 3) iOS /// Notification Service Extension so the NSE can read the RSA private key, @@ -48,7 +49,9 @@ class PushSecureStorage implements FlutterSecureStorageLike { @override Future write({required String key, required String? value}) => - pushSecureStorage.write(key: key, value: value); + value == null + ? pushSecureStorage.delete(key: key) + : pushSecureStorage.writeRetrying(key: key, value: value); @override Future delete({required String key}) => diff --git a/lib/session/session_manager.dart b/lib/session/session_manager.dart index 58b25af..5b6847c 100644 --- a/lib/session/session_manager.dart +++ b/lib/session/session_manager.dart @@ -8,6 +8,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../api/marianumconnect/auth/token_storage.dart'; import '../push/push_secure_storage.dart'; +import '../storage/keychain_write.dart'; import '../utils/exponential_backoff.dart'; import '../utils/random_id.dart'; import 'account_codec.dart'; @@ -165,7 +166,7 @@ class SessionManager { ...sessionVaultFields(session), ...await _tokenStorage.readAll(), }; - await _secureStorage.write( + await _secureStorage.writeRetrying( key: _vaultKey(entry.id), value: encodeVault(fields), ); @@ -209,7 +210,7 @@ class SessionManager { Future _saveIndex(AccountIndex index) async { accounts.value = index; - await _secureStorage.write(key: _indexKey, value: index.encode()); + await _secureStorage.writeRetrying(key: _indexKey, value: index.encode()); } static int _now() => DateTime.now().millisecondsSinceEpoch; @@ -234,7 +235,10 @@ class SessionManager { await setAppPassword(appPassword); await clearAppPasswordTalk(); _updateNextcloud((nc) => nc.copyWith(usesLoginFlow: true)); - await _secureStorage.write(key: SessionKeys.loginFlow, value: 'true'); + await _secureStorage.writeRetrying( + key: SessionKeys.loginFlow, + value: 'true', + ); } Future setAppPasswordTalk(String appPassword) async { @@ -258,7 +262,7 @@ class SessionManager { Future _writeSecret(String key, String? value) => value == null ? _secureStorage.delete(key: key) - : _secureStorage.write(key: key, value: value); + : _secureStorage.writeRetrying(key: key, value: value); // App passwords live in the push-shared (group-scoped) keystore so the iOS // Notification Service Extension can authenticate Nextcloud calls too. That @@ -269,7 +273,7 @@ class SessionManager { if (value == null) { await pushSecureStorage.delete(key: key); } else { - await pushSecureStorage.write(key: key, value: value); + await pushSecureStorage.writeRetrying(key: key, value: value); } } on Object { // ignore — see above @@ -362,7 +366,10 @@ class SessionManager { namespace: index.accounts.isEmpty ? '' : null, now: _now(), ); - await _secureStorage.write(key: _indexKey, value: index.encode()); + await _secureStorage.writeRetrying( + key: _indexKey, + value: index.encode(), + ); } accounts.value = index; return; @@ -385,11 +392,11 @@ class SessionManager { final hasSecure = (await _secureStorage.read(key: SessionKeys.username)) != null; if (!hasSecure) { - await _secureStorage.write( + await _secureStorage.writeRetrying( key: SessionKeys.username, value: legacyUsername, ); - await _secureStorage.write( + await _secureStorage.writeRetrying( key: SessionKeys.password, value: legacyPassword, ); @@ -408,7 +415,7 @@ class SessionManager { if (value == null) continue; // Same account+service: the legacy item has to go before the re-add. await _legacySecureStorage.delete(key: field); - await _secureStorage.write(key: field, value: value); + await _secureStorage.writeRetrying(key: field, value: value); } } } diff --git a/lib/storage/keychain_write.dart b/lib/storage/keychain_write.dart new file mode 100644 index 0000000..f1c92cd --- /dev/null +++ b/lib/storage/keychain_write.dart @@ -0,0 +1,34 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +/// `errSecDuplicateItem`: the iOS keychain refused to add an item that is +/// already there. +const int kErrSecDuplicateItem = -25299; + +/// Whether [error] is the iOS keychain's "item already exists" failure. +/// flutter_secure_storage surfaces it as a PlatformException whose details +/// (and message) carry the OSStatus. +bool isKeychainDuplicateItem(Object error) { + if (error is! PlatformException) return false; + if (error.details == kErrSecDuplicateItem) return true; + return error.message?.contains('$kErrSecDuplicateItem') ?? false; +} + +extension KeychainWrite on FlutterSecureStorage { + /// [write] that survives `errSecDuplicateItem`. The plugin's iOS write is + /// check-then-add/update and not atomic: a concurrent write to the same key + /// — or an existence check that itself failed (seen with the disk full) — + /// ends in SecItemAdd on an existing item. The second attempt finds the + /// item and updates it. + Future writeRetrying({ + required String key, + required String value, + }) async { + try { + await write(key: key, value: value); + } on PlatformException catch (e) { + if (!isKeychainDuplicateItem(e)) rethrow; + await write(key: key, value: value); + } + } +} diff --git a/lib/widget/avatar_disk_cache.dart b/lib/widget/avatar_disk_cache.dart index fa0ecbe..ba12bfc 100644 --- a/lib/widget/avatar_disk_cache.dart +++ b/lib/widget/avatar_disk_cache.dart @@ -39,18 +39,24 @@ class AvatarDiskCache { /// Kicks off directory resolution so [readSync] can hit on the very first /// avatar of a session. Fire-and-forget from app start; safe to call twice. - void warmUp() => unawaited(_directory()); + void warmUp() => unawaited(_directory().then((_) {}, onError: (Object _) {})); Future _directory() { return _dirFuture ??= _resolveDirectory(); } Future _resolveDirectory() async { - final base = await getApplicationCacheDirectory(); - final dir = Directory('${base.path}/$_subDirectory'); - await dir.create(recursive: true); - _dirPath = dir.path; - return dir; + try { + final base = await getApplicationCacheDirectory(); + final dir = Directory('${base.path}/$_subDirectory'); + await dir.create(recursive: true); + _dirPath = dir.path; + return dir; + } on Object { + // Not memoized (disk full at launch): the next call tries again. + _dirFuture = null; + rethrow; + } } /// File-safe, prefix-evictable name. The subject id is hex-encoded so it can diff --git a/lib/widget/file_pick.dart b/lib/widget/file_pick.dart index 60aff87..41b9934 100644 --- a/lib/widget/file_pick.dart +++ b/lib/widget/file_pick.dart @@ -53,13 +53,21 @@ class FilePick { return result?.files.map((e) => e.path).nonNulls.toList(); } + static bool _inFlight = false; + + /// A second tap while the native picker is already open (double tap, or a + /// tap on another source tile) would make the plugin throw instead of + /// opening a second picker; it is treated as a cancel. static Future _guarded( BuildContext context, Future Function() pick, ) async { + if (_inFlight) return null; + _inFlight = true; try { return await pick(); } on PlatformException catch (e) { + if (_isAlreadyActive(e)) return null; final denied = PermissionException.fromPlatform(e); if (!context.mounted) return null; if (denied != null) { @@ -71,9 +79,15 @@ class FilePick { } catch (e) { if (context.mounted) _showErrorDialog(context, e); return null; + } finally { + _inFlight = false; } } + // Android reports `already_active`, iOS `multiple_request`. + static bool _isAlreadyActive(PlatformException e) => + e.code == 'already_active' || e.code == 'multiple_request'; + static void _showPermissionDialog( BuildContext context, PermissionException denied, diff --git a/lib/widget/prosemirror/pm_image_view.dart b/lib/widget/prosemirror/pm_image_view.dart index f9f9e73..4905a75 100644 --- a/lib/widget/prosemirror/pm_image_view.dart +++ b/lib/widget/prosemirror/pm_image_view.dart @@ -42,7 +42,7 @@ class PmImageView extends StatelessWidget { final available = constraints.maxWidth.isFinite ? constraints.maxWidth : MediaQuery.sizeOf(context).width; - final targetWidth = _resolveWidth(node.width, available); + final targetWidth = resolvePmImageWidth(node.width, available); Widget image = ConstrainedBox( constraints: BoxConstraints(maxWidth: targetWidth ?? available), @@ -121,16 +121,20 @@ class PmImageView extends StatelessWidget { } } - double? _resolveWidth(String? width, double available) { - if (width == null) return null; - final length = parsePmCssLength(width); - if (length == null) return null; - final value = length.value; - final resolved = switch (length.unit) { - '%' => available * value / 100, - 'em' || 'rem' => value * 16, - _ => value, - }; - return resolved.clamp(1, available); - } +} + +/// Resolves a CSS `width` attribute against [available] pixels, or `null` when +/// the image should simply fill what it gets. Layout may hand out zero width +/// mid-animation, which would otherwise invert the clamp bounds. +double? resolvePmImageWidth(String? width, double available) { + if (width == null || available < 1) return null; + final length = parsePmCssLength(width); + if (length == null) return null; + final value = length.value; + final resolved = switch (length.unit) { + '%' => available * value / 100, + 'em' || 'rem' => value * 16, + _ => value, + }; + return resolved.clamp(1, available); } diff --git a/lib/widget/route_transition_gate.dart b/lib/widget/route_transition_gate.dart index 6b2c42d..9577615 100644 --- a/lib/widget/route_transition_gate.dart +++ b/lib/widget/route_transition_gate.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; import 'app_progress_indicator.dart'; @@ -23,8 +25,28 @@ import 'app_progress_indicator.dart'; /// wraps every tab screen in an animated `Transform.translate` and builds a tab /// lazily on first activation, which is how the ticker's in-place PDF pages hit /// the same window with no route animation in sight. +/// +/// Neither covers a subtree that is *moved* (GlobalKey) under a fresh +/// transform while already settled — Android's predictive back does exactly +/// that: on gesture start it swaps the route's transition widgets, and the +/// route content (`_subtreeKey`) lands under a brand-new `Transform.scale` +/// with no animation status change. Flutter rebuilds every stateful widget in +/// a moved subtree, so the gate re-checks its ancestors on each build and +/// re-gates while any of them has not been laid out yet. +/// +/// The one thing the gate must *not* do is unmount the subtree while a pointer +/// is down on it: pointer events keep going to the hit-test path captured at +/// pointer-down, and `SfPdfViewer` dereferences its `context` in the handler +/// after dispose. iOS' back swipe hits both the edge detector and the page, so +/// the pop starts with the finger still on the viewer — the subtree is held +/// until the pointer lifts. An unlaid-out ancestor still wins, since that +/// crash is certain in the very same frame. class RouteTransitionGate extends StatefulWidget { - const RouteTransitionGate({super.key, required this.builder, this.placeholder}); + const RouteTransitionGate({ + super.key, + required this.builder, + this.placeholder, + }); final WidgetBuilder builder; @@ -45,6 +67,9 @@ class _RouteTransitionGateState extends State { bool _settled = false; bool _settleScheduled = false; + int _activePointers = 0; + bool _childMounted = false; + @override void didChangeDependencies() { super.didChangeDependencies(); @@ -102,11 +127,58 @@ class _RouteTransitionGateState extends State { } @override - Widget build(BuildContext context) { - if (!_transitioning) { - if (_settled) return widget.builder(context); - _scheduleSettle(); + void deactivate() { + // About to be moved (or disposed); whatever we end up under must earn + // the settled state again. + _settled = false; + super.deactivate(); + } + + static bool _hasUnlaidOutAncestor(BuildContext context) { + var found = false; + context.visitAncestorElements((element) { + final renderObject = element is RenderObjectElement + ? element.renderObject + : null; + if (renderObject is RenderBox && !renderObject.hasSize) { + found = true; + return false; + } + return true; + }); + return found; + } + + void _onPointerUp(PointerEvent _) { + _activePointers = math.max(0, _activePointers - 1); + if (_activePointers > 0 || !mounted) return; + // Held through a transition that has since finished: the subtree is + // mounted and laid out, nothing to re-earn. Otherwise let go now. + if (_transitioning) { + setState(() {}); + } else { + _settled = true; } + } + + @override + Widget build(BuildContext context) { + final blocked = _hasUnlaidOutAncestor(context); + if (blocked) _settled = false; + final hold = _childMounted && _activePointers > 0 && !blocked; + if (hold && !_transitioning) _settled = true; + if ((_settled && !_transitioning) || hold) { + _childMounted = true; + return Listener( + onPointerDown: (_) => _activePointers++, + onPointerUp: _onPointerUp, + onPointerCancel: _onPointerUp, + child: widget.builder(context), + ); + } + _childMounted = false; + _activePointers = 0; + if (!_transitioning) _scheduleSettle(); return widget.placeholder ?? const Center(child: AppProgressIndicator.large()); } diff --git a/test/api/errors/error_mapper_test.dart b/test/api/errors/error_mapper_test.dart index 36e3b73..cad2c7f 100644 --- a/test/api/errors/error_mapper_test.dart +++ b/test/api/errors/error_mapper_test.dart @@ -14,6 +14,9 @@ import 'package:marianum_mobile/api/errors/parse_exception.dart'; import 'package:marianum_mobile/api/errors/permission_exception.dart'; import 'package:nextcloud/nextcloud.dart'; +(String, bool) errorUserMessageAndRetry(Object error) => + (errorToUserMessage(error), errorAllowsRetry(error)); + void main() { group('errorToUserMessage', () { test('null falls back to the default message', () { @@ -165,6 +168,96 @@ void main() { expect(errorToUserMessage(ex), contains('Zu viele Anfragen')); expect(errorAllowsRetry(ex), isTrue); }); + + String davBody(String exception, String message) => + '\n' + '\n' + ' Sabre\\DAV\\Exception\\$exception\n' + ' $message\n' + ''; + + test('parseDavError extracts class and message', () { + final dav = parseDavError( + davBody( + 'MethodNotAllowed', + 'The resource you tried to create already exists', + ), + ); + expect(dav?.exception, 'MethodNotAllowed'); + expect(dav?.message, 'The resource you tried to create already exists'); + expect(parseDavError('nope'), isNull); + }); + + test('MKCOL 405 on an existing folder says so and allows no retry', () { + final ex = DynamiteApiException( + 405, + const {}, + davBody( + 'MethodNotAllowed', + 'The resource you tried to create already exists', + ), + ); + expect(errorToUserMessage(ex), contains('existiert bereits')); + expect( + errorToTechnicalDetails(ex), + 'HTTP 405 MethodNotAllowed: The resource you tried to create already exists', + ); + expect(errorAllowsRetry(ex), isFalse); + }); + + test('other DAV exceptions map to their own messages', () { + expect( + errorToUserMessage( + DynamiteApiException( + 412, + const {}, + davBody('PreconditionFailed', 'x'), + ), + ), + contains('Zielort'), + ); + expect( + errorToUserMessage( + DynamiteApiException(409, const {}, davBody('Conflict', 'x')), + ), + contains('übergeordnete Ordner'), + ); + expect( + errorUserMessageAndRetry( + DynamiteApiException(423, const {}, davBody('Locked', 'x')), + ), + ( + 'Der Eintrag wird gerade bearbeitet. Bitte versuche es gleich erneut.', + true, + ), + ); + expect( + errorToUserMessage( + DynamiteApiException( + 507, + const {}, + davBody('InsufficientStorage', 'x'), + ), + ), + contains('Speicherplatz'), + ); + }); + + test( + 'unknown DAV exceptions keep the status mapping but name the class', + () { + final ex = DynamiteApiException( + 404, + const {}, + davBody('NotFound', 'File not found'), + ); + expect(errorToUserMessage(ex), const NotFoundException().userMessage); + expect( + errorToTechnicalDetails(ex), + 'HTTP 404 NotFound: File not found', + ); + }, + ); }); group('errorToTechnicalDetails', () { diff --git a/test/storage/keychain_write_test.dart b/test/storage/keychain_write_test.dart new file mode 100644 index 0000000..d243e8f --- /dev/null +++ b/test/storage/keychain_write_test.dart @@ -0,0 +1,35 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/storage/keychain_write.dart'; + +void main() { + group('isKeychainDuplicateItem', () { + test('recognises the OSStatus in the details', () { + final e = PlatformException( + code: 'Unexpected security result code', + message: + 'Code: -25299, Message: The specified item already exists in the keychain.', + details: -25299, + ); + expect(isKeychainDuplicateItem(e), isTrue); + }); + + test('falls back to the message when details are missing', () { + final e = PlatformException( + code: 'Unexpected security result code', + message: 'Code: -25299, Message: duplicate', + ); + expect(isKeychainDuplicateItem(e), isTrue); + }); + + test('ignores other keychain errors and non-platform errors', () { + final e = PlatformException( + code: 'Unexpected security result code', + message: 'Code: -25308, Message: interaction not allowed', + details: -25308, + ); + expect(isKeychainDuplicateItem(e), isFalse); + expect(isKeychainDuplicateItem(StateError('x')), isFalse); + }); + }); +} diff --git a/test/widget/prosemirror/pm_image_width_test.dart b/test/widget/prosemirror/pm_image_width_test.dart new file mode 100644 index 0000000..99087b2 --- /dev/null +++ b/test/widget/prosemirror/pm_image_width_test.dart @@ -0,0 +1,31 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/widget/prosemirror/pm_image_view.dart'; + +void main() { + group('resolvePmImageWidth', () { + test('returns null without a width attribute', () { + expect(resolvePmImageWidth(null, 300), isNull); + }); + + test('returns null for unparseable widths', () { + expect(resolvePmImageWidth('auto', 300), isNull); + }); + + test('resolves px, %, em and rem', () { + expect(resolvePmImageWidth('120px', 300), 120); + expect(resolvePmImageWidth('50%', 300), 150); + expect(resolvePmImageWidth('2em', 300), 32); + expect(resolvePmImageWidth('2rem', 300), 32); + }); + + test('caps at the available width and floors at one pixel', () { + expect(resolvePmImageWidth('900px', 300), 300); + expect(resolvePmImageWidth('0px', 300), 1); + }); + + test('survives zero or sub-pixel available width', () { + expect(resolvePmImageWidth('50%', 0), isNull); + expect(resolvePmImageWidth('120px', 0.5), isNull); + }); + }); +} diff --git a/test/widget/route_transition_gate_test.dart b/test/widget/route_transition_gate_test.dart index 1fc216b..8f15ffb 100644 --- a/test/widget/route_transition_gate_test.dart +++ b/test/widget/route_transition_gate_test.dart @@ -11,7 +11,12 @@ void main() { Widget gatedPage() => Scaffold( body: RouteTransitionGate( placeholder: const SizedBox(key: placeholder), - builder: (_) => const SizedBox(key: gated), + // Hit-testable like the real viewer, so pointer tracking sees it. + builder: (_) => const ColoredBox( + key: gated, + color: Color(0x00000000), + child: SizedBox.expand(), + ), ), ); @@ -29,7 +34,9 @@ void main() { ) async { await pumpApp(tester); unawaited( - navigator(tester).push(MaterialPageRoute(builder: (_) => gatedPage())), + navigator( + tester, + ).push(MaterialPageRoute(builder: (_) => gatedPage())), ); await tester.pump(); await tester.pump(const Duration(milliseconds: 50)); @@ -45,7 +52,9 @@ void main() { testWidgets('swaps back to placeholder while the route pops', (tester) async { await pumpApp(tester); unawaited( - navigator(tester).push(MaterialPageRoute(builder: (_) => gatedPage())), + navigator( + tester, + ).push(MaterialPageRoute(builder: (_) => gatedPage())), ); await tester.pumpAndSettle(); expect(find.byKey(gated), findsOneWidget); @@ -63,13 +72,17 @@ void main() { ) async { await pumpApp(tester); unawaited( - navigator(tester).push(MaterialPageRoute(builder: (_) => gatedPage())), + navigator( + tester, + ).push(MaterialPageRoute(builder: (_) => gatedPage())), ); await tester.pumpAndSettle(); expect(find.byKey(gated), findsOneWidget); unawaited( - navigator(tester).push(MaterialPageRoute(builder: (_) => const Scaffold())), + navigator( + tester, + ).push(MaterialPageRoute(builder: (_) => const Scaffold())), ); await tester.pump(); await tester.pump(const Duration(milliseconds: 50)); @@ -84,6 +97,86 @@ void main() { expect(find.byKey(placeholder), findsNothing); }); + testWidgets('re-gates when the settled subtree moves under a fresh Transform', ( + tester, + ) async { + // Android's predictive back moves the route content (GlobalKey) under a + // brand-new Transform.scale without any animation status change. The + // padding changes the constraints so the moved subtree is laid out nested + // under the transform's first layout, which is when `localToGlobal` throws. + final subtree = GlobalKey(); + var wrapped = false; + late StateSetter rebuild; + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (context, setState) { + rebuild = setState; + final child = KeyedSubtree( + key: subtree, + child: Scaffold( + body: RouteTransitionGate( + placeholder: const SizedBox(key: placeholder), + builder: (_) => const _LocalToGlobalProbe(key: gated), + ), + ), + ); + if (!wrapped) return child; + return Padding( + padding: const EdgeInsets.all(1), + child: Transform.scale(scale: 1, child: child), + ); + }, + ), + ), + ); + await tester.pump(); + await tester.pump(); + expect(find.byKey(gated), findsOneWidget); + + rebuild(() => wrapped = true); + await tester.pump(); + expect(tester.takeException(), isNull); + expect(find.byKey(placeholder), findsOneWidget); + + await tester.pump(); + expect(find.byKey(gated), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('keeps the child mounted while a back swipe drives the pop', ( + tester, + ) async { + // iOS back swipe: the edge detector is translucent, so the finger that + // drives the pop animation is also down on the viewer. Unmounting it now + // would route the next pointer move into a disposed State. (A programmatic + // pop is harmless — the Navigator cancels active pointers first.) + await pumpApp(tester); + unawaited( + navigator( + tester, + ).push(MaterialPageRoute(builder: (_) => gatedPage())), + ); + await tester.pumpAndSettle(); + expect(find.byKey(gated), findsOneWidget); + + final gesture = await tester.startGesture(const Offset(5, 300)); + await gesture.moveBy(const Offset(60, 0)); + await tester.pump(); + await gesture.moveBy(const Offset(60, 0)); + await tester.pump(); + // Pop animation is running (status reverse), finger still down. + expect(find.byKey(gated), findsOneWidget); + expect(find.byKey(placeholder), findsNothing); + expect(tester.takeException(), isNull); + + await gesture.up(); + await tester.pump(); + expect(find.byKey(gated), findsNothing); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }, variant: TargetPlatformVariant.only(TargetPlatform.iOS)); + testWidgets('holds the child back for one frame without a route animation', ( tester, ) async { @@ -99,3 +192,25 @@ void main() { expect(find.byKey(placeholder), findsNothing); }); } + +/// Mimics SfPdfViewer: a stateful widget whose LayoutBuilder resolves a +/// child's global position during layout. +class _LocalToGlobalProbe extends StatefulWidget { + const _LocalToGlobalProbe({super.key}); + + @override + State<_LocalToGlobalProbe> createState() => _LocalToGlobalProbeState(); +} + +class _LocalToGlobalProbeState extends State<_LocalToGlobalProbe> { + final _viewport = GlobalKey(); + + @override + Widget build(BuildContext context) => LayoutBuilder( + builder: (context, constraints) { + final box = _viewport.currentContext?.findRenderObject() as RenderBox?; + if (box != null && box.hasSize) box.localToGlobal(Offset.zero); + return SizedBox.expand(key: _viewport); + }, + ); +}