Skip to content

Crash when editing and saving a PDF for the second time on some Android devices #622

Description

@deeppwebster

Package versions

When a PDF is edited and saved, reopening the saved PDF and editing it again and save causes the application to crash on some Android devices. In some cases, previously saved annotations/highlights are also missing after reopening the generated PDF.

pdf: 3.11.3
pdfx: 2.9.2
image: 4.8.0

Platform

Android only

Tested on:

  • Android 12
  • Android 16
  • Samsung devices
  • Motorola devices
  • Vivo devices

The issue does NOT occur on iOS.

Steps to reproduce

  1. Open any PDF.
  2. Add text or annotations.
  3. Save the PDF.
  4. Open the saved PDF again.
  5. Add another annotation.
  6. Save again.

Expected behavior

The edited PDF should save successfully every time.

Actual behavior

The application crashes while generating/saving the PDF.

This issue occurs only on some Android devices.

Additionally, in some cases previously saved annotations disappear after reopening the generated PDF.

Notes

  • Happens with every PDF.
  • First save succeeds.
  • Second save causes the crash.
  • iOS works correctly.

Sample code

Future _save() async {
if (_isSaving) return;
setState(() => _isSaving = true);
_commitText();
await SchedulerBinding.instance.endOfFrame;
setState(() => _saving = true);

bool loadingDialogShown = false;

if (mounted) {
  loadingDialogShown = true;
  showDialog(
    context: context,
    barrierDismissible: false,
    builder: (_) => const PopScope(
      canPop: false,
      child: Dialog(
        backgroundColor: Color(0xFF1C1C1E),
        child: Padding(
          padding: EdgeInsets.all(24),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              CircularProgressIndicator(color: Color(0xFF4FC3F7)),
              SizedBox(height: 16),
              Text(
                'Saving PDF, please wait...',
                style: TextStyle(color: Colors.white, fontSize: 14),
              ),
            ],
          ),
        ),
      ),
    ),
  );
}

try {
  // ── Ensure un-rendered pages are rendered (render in parallel batches) ──
  await _ensureAllPagesRendered();

  // ✅ FIX 3: Build the page list using cached composites where available,
  // then fall back to in-memory compositing for remaining dirty pages —
  // all done in parallel batches of 3.
  final List<Uint8List?> slots = List.filled(_totalPages, null);
  const batchSize = 3;

  for (int start = 0; start < _totalPages; start += batchSize) {
    final end = (start + batchSize).clamp(0, _totalPages);
    final futures = <Future<void>>[];

    for (int i = start; i < end; i++) {
      final idx = i;
      futures.add(() async {
        if (_pageImages[idx] == null) return; // render failed, skip

        final hasAnnotations = _annotations[idx]?.isNotEmpty ?? false;

        if (!hasAnnotations) {
          // ✅ FIX 1: Pass PNG directly — pw.MemoryImage handles PNG natively.
          // No redundant JPEG re-encode for clean pages.
          slots[idx] = _pageImages[idx]!;
        } else if (_compositedCache.containsKey(idx) &&
            !_dirtyPages.contains(idx)) {
          // ✅ Pre-baked — instant, no work to do.
          slots[idx] = _compositedCache[idx]!;
        } else {
          // Fallback: composite now (should be rare since _onPanEnd
          // pre-composites in background after every stroke).
          slots[idx] = await _renderPageInMemory(idx);
        }
      }());
    }

    await Future.wait(futures);
  }

  final renderedPages = slots.whereType<Uint8List>().toList();

  // PDF assembly in background isolate.
  // final pdfBytes = await compute(_buildPdfFromPngs, renderedPages);
  final pdfBytes = kIsWeb
      ? await _buildPdfFromPngs(
          renderedPages,
        ) // web: no real isolate anyway
      : await compute(
          _buildPdfFromPngs,
          renderedPages,
        ); // native: real isolate

  final base = widget.fileName.contains('.')
      ? widget.fileName.substring(0, widget.fileName.lastIndexOf('.'))
      : widget.fileName;
  final outName = 'EDITED_$base.pdf';

  if (mounted && loadingDialogShown) {
    Navigator.of(context, rootNavigator: true).pop();
    loadingDialogShown = false;
  }

  if (mounted) setState(() => _saving = false);

  await _showSaveShareSheet(context, pdfBytes, outName);
} catch (e) {
  debugPrint('PDF save error: $e');
  if (mounted && loadingDialogShown) {
    Navigator.of(context, rootNavigator: true).pop();
  }
  if (mounted) {
    setState(() => _saving = false);
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        content: Text('Save failed: $e'),
        backgroundColor: Colors.red,
      ),
    );
  }
} finally {
  if (mounted) {
    setState(() {
      _saving = false;
      _isSaving = false;
    });
  }
}

}

Future _ensureAllPagesRendered() async {
const batchSize = 4;
for (int start = 0; start < _totalPages; start += batchSize) {
final end = (start + batchSize).clamp(0, _totalPages);
final futures = <Future>[];
for (int i = start; i < end; i++) {
if (_pageImages[i] == null) {
futures.add(_renderPage(i));
}
}
if (futures.isNotEmpty) await Future.wait(futures);
}
}

Future _renderPageInMemory(int pageIdx) async {
if (_pageImages[pageIdx] == null) {
await _renderPage(pageIdx);
}
if (_pageImages[pageIdx] == null) {
throw Exception('Page $pageIdx render failed');
}

final pageBytes = _pageImages[pageIdx]!;
final items = _annotations[pageIdx] ?? [];

final codec = await ui.instantiateImageCodec(pageBytes);
final frame = await codec.getNextFrame();
final baseImage = frame.image;

final w = baseImage.width.toDouble();
final h = baseImage.height.toDouble();

final displaySize = _displaySizes[pageIdx] ?? Size(w, h);
final scaleX = w / displaySize.width;
final scaleY = h / displaySize.height;

final scaledItems = items.map((item) {
  return item.copyWith(
    pts: item.pts.map((p) => Offset(p.dx * scaleX, p.dy * scaleY)).toList(),
    s: item.s != null
        ? Offset(item.s!.dx * scaleX, item.s!.dy * scaleY)
        : null,
    e: item.e != null
        ? Offset(item.e!.dx * scaleX, item.e!.dy * scaleY)
        : null,
    txtPos: item.txtPos != null
        ? Offset(item.txtPos!.dx * scaleX, item.txtPos!.dy * scaleY)
        : null,
    sw: item.sw * ((scaleX + scaleY) / 2),
    fontSize: item.fontSize * ((scaleX + scaleY) / 2),
    textMaxWidth: item.textMaxWidth * scaleX,
  );
}).toList();

final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder, Rect.fromLTWH(0, 0, w, h));
canvas.drawImage(baseImage, Offset.zero, Paint());
_PagePainter(items: scaledItems).paint(canvas, Size(w, h));
final picture = recorder.endRecording();

final rendered = await picture.toImage(w.toInt(), h.toInt());

// rawRgba capture — near-instant, no PNG encoder involved
// final bd = await rendered.toByteData(format: ui.ImageByteFormat.rawRgba);

// baseImage.dispose();
// rendered.dispose();

// // JPEG encode in a background isolate
// return compute(
//   _encodeRgbaToJpg,
//   _RgbaEncodeArgs(
//     bytes: bd!.buffer.asUint8List(),
//     width: w.toInt(),
//     height: h.toInt(),
//   ),
// );

if (kIsWeb) {
  // Flutter web's PNG encoder is WAY faster than image-package JPEG on web
  final bd = await rendered.toByteData(format: ui.ImageByteFormat.png);
  baseImage.dispose();
  rendered.dispose();
  return bd!.buffer.asUint8List();
} else {
  final bd = await rendered.toByteData(format: ui.ImageByteFormat.rawRgba);
  baseImage.dispose();
  rendered.dispose();
  return compute(
    _encodeRgbaToJpg,
    _RgbaEncodeArgs(
      bytes: bd!.buffer.asUint8List(),
      width: w.toInt(),
      height: h.toInt(),
    ),
  );
}

}

Future _buildPdfFromPngs(List pages) async {
final doc = pw.Document();
for (final pageBytes in pages) {
final image = pw.MemoryImage(pageBytes);
doc.addPage(
pw.Page(
pageFormat: pw_color.PdfPageFormat(
image.width!.toDouble(),
image.height!.toDouble(),
),
margin: pw.EdgeInsets.zero,
build: (ctx) => pw.Image(image, fit: pw.BoxFit.fill),
),
);
}
final bytes = await doc.save();
return Uint8List.fromList(bytes);
}

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions