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

+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());
}