fixed client-reported crashes in the pdf viewer, image picker and prosemirror images, mapped webdav errors to user messages and hardened keychain, cache and push stores against io failures

This commit is contained in:
MineTec committed 2026-10-12 17:00:14 +02:00
1 parent c5592f117b
commit 23e4510bf8
19 files changed
+545 -55

No files matched your search

+10 -4
View File
@@ -52,10 +52,16 @@ class CacheStore {
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;
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;
}
}();
}
+50
View File
@@ -54,11 +54,61 @@ AppException? _dioToAppException(DioException error) {
}
}
final RegExp _davException = RegExp(
r'<s:exception>\s*([^<]*?)\s*</s:exception>',
);
final RegExp _davMessage = RegExp(r'<s:message>\s*([^<]*?)\s*</s:message>');
/// 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'
+1 -1
View File
@@ -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,
);
}
@@ -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);
}
}
}
@@ -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;
}
+4 -2
View File
@@ -17,9 +17,11 @@ class NotificationService {
Future<void> 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
+10 -2
View File
@@ -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<void> _write(String chatToken, List<ThreadMessage> 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<void> _write(String chatToken, List<ThreadMessage> 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');
}
}
}
+10 -2
View File
@@ -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<void> 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<void> 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<NidEntry?> get(int nid) async {
final data = await _db.collection(_collection).doc('$nid').get();
+4 -1
View File
@@ -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<void> 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<void> delete({required String key}) =>
+16 -9
View File
@@ -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<void> _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<void> setAppPasswordTalk(String appPassword) async {
@@ -258,7 +262,7 @@ class SessionManager {
Future<void> _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);
}
}
}
+34
View File
@@ -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<void> 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);
}
}
}
+12 -6
View File
@@ -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> _directory() {
return _dirFuture ??= _resolveDirectory();
}
Future<Directory> _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
+14
View File
@@ -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<T?> _guarded<T>(
BuildContext context,
Future<T?> 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,
+17 -13
View File
@@ -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);
}
+77 -5
View File
@@ -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<RouteTransitionGate> {
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<RouteTransitionGate> {
}
@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());
}
+93
View File
@@ -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) =>
'<?xml version="1.0" encoding="utf-8"?>\n'
'<d:error xmlns:d="DAV:" xmlns:s="http://sabredav.org/ns">\n'
' <s:exception>Sabre\\DAV\\Exception\\$exception</s:exception>\n'
' <s:message>$message</s:message>\n'
'</d:error>';
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('<html>nope</html>'), 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', () {
+35
View File
@@ -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);
});
});
}
@@ -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);
});
});
}
+120 -5
View File
@@ -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<void>(builder: (_) => gatedPage())),
navigator(
tester,
).push(MaterialPageRoute<void>(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<void>(builder: (_) => gatedPage())),
navigator(
tester,
).push(MaterialPageRoute<void>(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<void>(builder: (_) => gatedPage())),
navigator(
tester,
).push(MaterialPageRoute<void>(builder: (_) => gatedPage())),
);
await tester.pumpAndSettle();
expect(find.byKey(gated), findsOneWidget);
unawaited(
navigator(tester).push(MaterialPageRoute<void>(builder: (_) => const Scaffold())),
navigator(
tester,
).push(MaterialPageRoute<void>(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<void>(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);
},
);
}