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:
1 parent
c5592f117b
commit
23e4510bf8
19 files changed
+545
-55
No files matched your search
@@ -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', () {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user